Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
Streaming SSR i Edge Runtime
W tej lekcji8
Wyobraź sobie, że zamiast wysyłać cały statek kosmiczny jednym transportem, wysyłasz go moduł po module - kokpit natychmiast, a systemy nawigacyjne i ładownie dolatują w tle. Dokładnie tak działa Streaming SSR - serwer zaczyna wysyłać HTML do przeglądarki jeszcze zanim cały rendering zostanie ukończony. A Edge Runtime przenosi ten proces bliżej użytkownika - jak stacje przekaźnikowe rozmieszczone po całej galaktyce.
Tradycyjny SSR vs Streaming SSR
Tradycyjny SSR generuje cały HTML na serwerze, zanim wyśle go do przeglądarki. Użytkownik czeka na najwolniejszy komponent:
1// Tradycyjny SSR - użytkownik czeka na WSZYSTKO
2// Serwer: [renderuj Header][renderuj Dane][renderuj Footer] -> wyślij całość
3// Czas: 500ms + 2000ms + 100ms = 2600ms zanim cokolwiek zobaczy
4
5// Streaming SSR - fragmenty wysyłane natychmiast
6// Serwer: [Header gotowy] -> wyślij! [Dane gotowe] -> wyślij! [Footer] -> wyślij!
7// Czas: 500ms do pierwszego fragmentu, reszta dochodzi w tlerenderToPipeableStream - API streamingu
React 18 wprowadził renderToPipeableStream, strumieniowego następcę renderToString, który musiał wyrenderować całą stronę przed wysłaniem pierwszego bajtu:
1import { renderToPipeableStream } from 'react-dom/server';
2
3// Serwer Node.js z Streaming SSR
4app.get('/', (req, res) => {
5 const { pipe } = renderToPipeableStream(
6 <App />,
7 {
8 bootstrapScripts: ['/main.js'],
9 onShellReady() {
10 // Powłoka (shell) gotowa - wyślij natychmiast
11 res.setHeader('Content-Type', 'text/html');
12 pipe(res);
13 },
14 onShellError(error) {
15 // Błąd w krytycznej części - wyślij fallback
16 res.statusCode = 500;
17 res.send('<h1>Server Error</h1>');
18 },
19 onAllReady() {
20 // Wszystko gotowe (przydatne dla crawlerów/SEO)
21 },
22 onError(error) {
23 console.error('Streaming error:', error);
24 }
25 }
26 );
27});onShellReady jest wywoływany gdy główna "powłoka" strony jest gotowa - czyli wszystko poza granicami <Suspense>. Użytkownik widzi szkielet strony natychmiast.
renderToReadableStream - Edge Runtime API
Dla Edge Runtime (Cloudflare Workers, Vercel Edge, Deno Deploy) używamy renderToReadableStream, które zwraca Web Streams API:
1import { renderToReadableStream } from 'react-dom/server';
2
3// Edge Runtime handler
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}Różnica między API:
renderToPipeableStream- Node.js Streams (Node.js runtime)renderToReadableStream- Web Streams API (Edge Runtime, Deno, przeglądarki)
Od React 19.2 renderToReadableStream działa też w Node.js, ale zespół Reacta nadal zaleca tam renderToPipeableStream: strumienie Node.js są w Node szybsze, a Web Streams domyślnie nie obsługują kompresji.
Suspense jako granica streamingu
<Suspense> definiuje granice streamingu - serwer wysyła fallback i później podmienia go na gotowy HTML:
1function SpaceStationDashboard() {
2 return (
3 <div>
4 {/* Renderowane natychmiast - część "shell" */}
5 <Header title="Stacja Kosmiczna Alpha" />
6 <Navigation />
7
8 {/* Każda sekcja streamuje się niezależnie */}
9 <Suspense fallback={<Skeleton type="missions" />}>
10 <MissionList /> {/* Dane ładują 800ms */}
11 </Suspense>
12
13 <Suspense fallback={<Skeleton type="crew" />}>
14 <CrewStatus /> {/* Dane ładują 1500ms */}
15 </Suspense>
16
17 <Suspense fallback={<Skeleton type="telemetry" />}>
18 <TelemetryData /> {/* Dane ładują 2200ms */}
19 </Suspense>
20 </div>
21 );
22}
23
24// Przebieg streamingu:
25// 1. [0ms] Serwer wysyła: Header + Navigation + 3x Skeleton
26// 2. [800ms] Serwer dosyła: MissionList (podmienia Skeleton)
27// 3. [1500ms] Serwer dosyła: CrewStatus (podmienia Skeleton)
28// 4. [2200ms] Serwer dosyła: TelemetryData (podmienia Skeleton)Błędy i dane w granicach Suspense
Suspense obsługuje tylko czekanie na dane. Jeśli pobieranie się nie uda, komponent rzuci błąd, a ten złapie najbliższy Error Boundary. Dlatego wolną sekcję owija się w dwie warstwy: Error Boundary na zewnątrz i Suspense w środku. Dane w takim komponencie wygodnie pobierzesz hookiem useSuspenseQuery z TanStack Query, który zawiesza komponent do chwili nadejścia danych:
1import { Suspense } from 'react';
2import { ErrorBoundary } from 'react-error-boundary';
3import { useSuspenseQuery } from '@tanstack/react-query';
4
5function MissionList() {
6 // data jest zawsze zdefiniowane: ładowanie obsługuje Suspense, a błędy Error Boundary
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}Podczas ładowania widać szkielet z Suspense, a gdy zapytanie się nie powiedzie, ErrorBoundary z pakietu react-error-boundary pokaże MissionsError w miejscu całej sekcji. Na serwerze działa to podobnie: jeśli komponent wewnątrz granicy Suspense rzuci błąd w trakcie streamingu, React nie przerywa odpowiedzi, tylko wysyła fallback tej granicy i ponawia renderowanie już w przeglądarce. Dopiero gdy i tam się nie uda, błąd trafia do najbliższego Error Boundary. Błąd w samej powłoce, poza wszystkimi granicami, wywołuje onShellError.
Edge Runtime - obliczenia bliżej użytkownika
Edge Runtime to lekkie środowisko wykonawcze działające na "krawędzi" sieci (edge), czyli na serwerach CDN rozsianych po całym świecie, blisko użytkownika. Zamiast pełnego Node.js uruchamia kod w izolatach silnika V8 (tego samego, który wykonuje JavaScript w Chrome) i daje mu Web APIs: fetch, Request, Response i Web Streams. Izolat startuje mniej więcej sto razy szybciej niż proces Node.js, dlatego zimny start trwa tu zaledwie kilka milisekund. Tak wygląda strumieniowanie Reacta w Cloudflare Workers:
1// worker.jsx - Cloudflare Workers (izolaty V8, Web APIs zamiast Node.js)
2import { renderToReadableStream } from 'react-dom/server';
3import App from './App';
4
5export default {
6 async fetch(request) {
7 // Cloudflare dołącza do żądania dane o lokalizacji użytkownika
8 const city = request.cf?.city ?? 'nieznane miasto';
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};Worker nie ma dostępu do systemu plików ani modułów Node.js, za to odpowiada z serwera najbliższego użytkownikowi. Lokalizację dostarcza tu platforma przez request.cf, a nie React. W Next.js obiekt request.geo zniknął w wersji 15, a na Vercel miasto odczytasz funkcją geolocation() z pakietu @vercel/functions.
Różnice: Node.js vs Edge Runtime
Tabela pokazuje, czym płacisz za błyskawiczny start na brzegu sieci. Czasy zimnego startu są orientacyjne i zależą od platformy:
| Cecha | Node.js Runtime | Edge Runtime |
|---|---|---|
| Lokalizacja | Jeden lub kilka regionów | Serwery CDN na całym świecie |
| Zimny start (cold start) | 200-500ms (zimna funkcja serverless) | 1-5ms (izolat V8) |
| Rozmiar kodu | Duży limit (np. 250 MB na Vercel) | 1-4 MB po kompresji (Vercel, zależnie od planu) |
| API | Pełne Node.js API | Web APIs, bez systemu plików i modułów Node.js |
| Streaming | renderToPipeableStream | renderToReadableStream |
| Bazy danych | Dowolne | Sterowniki działające przez HTTP (np. Turso, Neon) |
Edge Runtime w Next.js 16
Przewaga szybkiego startu maleje, bo platformy skracają też zimny start Node.js. Fluid compute na Vercel przechowuje skompilowany bajtkod i rozgrzewa funkcje w produkcji, dlatego Vercel zaleca dziś przenoszenie funkcji z Edge na Node.js. Next.js poszedł tą samą drogą: w wersji 16 plik middleware.ts zmienił nazwę na proxy.ts i działa zawsze na Node.js, a w wersji 16.3 eksport runtime = 'edge' w plikach stron i route handlerów stał się przestarzały. Next.js ostrzega przy nim i radzi go usunąć, bo domyślny Node.js nie wymaga żadnej konfiguracji, a Vercel uruchamia takie trasy na Node.js:
1// proxy.js (Next.js 16, dawniej middleware.js) - zawsze Node.js runtime
2import { NextResponse } from 'next/server';
3
4export function proxy(request) {
5 const isDashboard = request.nextUrl.pathname.startsWith('/dashboard');
6
7 // Bez sesji przekieruj na logowanie, zanim strona zacznie się renderować
8 if (isDashboard && !request.cookies.has('session')) {
9 return NextResponse.redirect(new URL('/login', request.url));
10 }
11 return NextResponse.next();
12}Proxy uruchamia się przed renderowaniem każdej trasy, więc w prawdziwym projekcie zawęź go opcją matcher w obiekcie config. Edge Runtime nie znika jednak z ekosystemu: zostaje naturalnym środowiskiem platform zbudowanych na izolatach, jak Cloudflare Workers czy Deno Deploy, a renderToReadableStream działa tam bez zmian.
Progressive Hydration
Streaming SSR naturalnie wspiera progressive hydration - React hydruje komponenty w miarę ich pojawiania się:
1// Schemat progressive hydration:
2// 1. Serwer streamuje HTML -> przeglądarka wyświetla
3// 2. JavaScript się ładuje
4// 3. React hydruje komponenty które już są w DOM
5// 4. Nowe fragmenty HTML przychodzą ze streamu
6// 5. React hydruje je natychmiast po otrzymaniu
7
8// Selective hydration - React priorytetyzuje
9// komponenty z którymi użytkownik interaguje:
10function App() {
11 return (
12 <div>
13 {/* Użytkownik kliknął tu - React hydruje to PIERWSZE */}
14 <Suspense fallback={<Skeleton />}>
15 <InteractivePanel />
16 </Suspense>
17
18 {/* To czeka w kolejce */}
19 <Suspense fallback={<Skeleton />}>
20 <HeavyDataTable />
21 </Suspense>
22 </div>
23 );
24}Podsumowanie
Streaming SSR i Edge Runtime to potężne narzędzia przyspieszające dostarczanie aplikacji:
- Streaming SSR - wysyła HTML fragmentami, użytkownik widzi stronę natychmiast
- renderToPipeableStream - API Node.js do streamingu
- renderToReadableStream - API Web Streams (Edge Runtime, Cloudflare Workers, Deno)
- Suspense boundaries - definiują granice streamingu, a Error Boundary nad nimi łapie błędy
- Edge Runtime - kod blisko użytkownika i szybki zimny start, ale w Next.js 16 domyślnym i zalecanym środowiskiem jest Node.js
- Progressive Hydration - React hydruje komponenty w miarę ich pojawiania się
Streaming SSR zmienia dostarczanie aplikacji jak systemy modułowe na stacji kosmicznej - każdy moduł startuje niezależnie, użytkownik nie czeka na całość. W przykładzie poniżej sekcje dopływają w kolejności gotowości danych, a po zaznaczeniu awarii przekaźnika i wysłaniu nowego żądania zobaczysz, jak Error Boundary łapie błąd jednej sekcji, a reszta strony działa dalej.
Kod do tej lekcji: App.jsx
1import { Component, Suspense, use, useCallback, useEffect, useState } from 'react';
2
3// === STREAMING SSR - symulacja w przeglądarce ===
4// Shell (nagłówek i układ) pojawia się od razu, a każda granica Suspense
5// pokazuje szkielet, dopóki jej dane nie dotrą. Sekcje przychodzą w kolejności
6// gotowości danych, a nie w kolejności na stronie.
7
8const DATA = {
9 missions: [
10 { id: 1, name: 'Ekspedycja na Marsa', status: 'aktywna', eta: '47 dni' },
11 { id: 2, name: 'Sonda Europa', status: 'planowana', eta: '182 dni' },
12 { id: 3, name: 'Baza Księżycowa', status: 'zakończona', eta: '-' },
13 ],
14 crew: [
15 { id: 1, name: 'Komandor Nova', role: 'Dowódca', ready: true },
16 { id: 2, name: 'Astro', role: 'Nawigator', ready: true },
17 { id: 3, name: 'Ra', role: 'Przewodnik', ready: false },
18 ],
19 telemetry: [
20 { label: 'Tlen', value: 96 },
21 { label: 'Paliwo', value: 78 },
22 { label: 'Kadłub', value: 99 },
23 { label: 'Łączność', value: 87 },
24 ],
25 earth: 'Centrum Ziemia potwierdza kurs. Powodzenia, załogo!',
26};
27
28const DELAYS = { missions: 600, earth: 900, crew: 1200, telemetry: 2000 };
29
30// Jedna obietnica na sekcję w danym żądaniu. Ta sama obietnica przy kolejnym
31// renderze pozwala hookowi use() odczytać gotowe dane.
32const requests = new Map();
33
34// Moment startu bieżącego żądania (do osi czasu)
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('Brak sygnału z przekaźnika na orbicie'));
43 else resolve(DATA[section]);
44 }, DELAYS[section]);
45 }));
46 }
47 return requests.get(key);
48}
49
50// Error Boundary: łapie błąd rzucony podczas renderowania sekcji
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>Łączność przerwana</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 Spróbuj ponownie
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}>Czekam na sekcję: {label}...</p>
92 </div>
93 );
94}
95
96function MissionPanel({ requestId, onReady }) {
97 const missions = use(loadSection('missions', requestId));
98 useEffect(() => {
99 onReady('Misje', '#00ff88');
100 }, [onReady]);
101
102 return (
103 <div style={styles.panel}>
104 <h3 style={styles.panelTitle}>Misje (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('Załoga', '#ffaa00');
120 }, [onReady]);
121
122 return (
123 <div style={styles.panel}>
124 <h3 style={styles.panelTitle}>Załoga (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('Telemetria', '#ff6b6b');
140 }, [onReady]);
141
142 return (
143 <div style={styles.panel}>
144 <h3 style={styles.panelTitle}>Telemetria (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}>Łączność z Ziemią (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: 'Shell (nagłówek i układ)', ms: 0, color: '#8892b0' }]);
179 // Sekcja zgłasza się po wyrenderowaniu, więc oś czasu pokazuje prawdziwą kolejność
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('Błąd łączności: ' + message, '#ff6b6b');
187 }, [report]);
188
189 const startRequest = () => {
190 requestStart = performance.now();
191 setAttempt(0);
192 setEvents([{ label: 'Shell (nagłówek i układ)', 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 ? 'Łączność z Ziemią' : 'Łączność z Ziemią (ponowna próba)';
198
199 return (
200 <div style={styles.container}>
201 <header style={styles.header}>
202 <h1 style={styles.title}>Streaming SSR - symulacja</h1>
203 <p style={styles.subtitle}>
204 Shell pojawia się od razu, a każda granica Suspense dosyła swoją sekcję, gdy dane są gotowe
205 </p>
206 <div style={styles.toolbar}>
207 <button style={styles.button} onClick={startRequest}>Nowe żądanie</button>
208 <label style={styles.checkbox}>
209 <input type="checkbox" checked={failNext} onChange={e => setFailNext(e.target.checked)} />
210 Awaria przekaźnika w następnym żądaniu
211 </label>
212 </div>
213 </header>
214
215 <section style={styles.shell}>
216 <h2 style={{ color: '#00d4ff', margin: 0, fontSize: '16px' }}>Stacja Alpha - Dashboard (żądanie #{request.id})</h2>
217 <p style={{ color: '#8892b0', margin: '2px 0 0', fontSize: '11px' }}>Shell: wysłany natychmiast (0 ms)</p>
218 </section>
219
220 <div style={styles.mainGrid}>
221 <div key={request.id} style={styles.grid}>
222 <Suspense fallback={<Skeleton label="Misje" />}>
223 <MissionPanel requestId={request.id} onReady={report} />
224 </Suspense>
225
226 <RelayBoundary onError={reportError} onRetry={() => setAttempt(a => a + 1)}>
227 <Suspense fallback={<Skeleton label="Łączność z Ziemią" />}>
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="Załoga" />}>
238 <CrewPanel requestId={request.id} onReady={report} />
239 </Suspense>
240
241 <Suspense fallback={<Skeleton label="Telemetria" />}>
242 <TelemetryPanel requestId={request.id} onReady={report} />
243 </Suspense>
244 </div>
245
246 <aside style={styles.timeline}>
247 <h3 style={styles.panelTitle}>Kolejność streamingu</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};Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaka jest główna różnica między renderToPipeableStream a renderToReadableStream?
2. Która cecha jest GŁÓWNĄ zaletą Edge Runtime w porównaniu do tradycyjnego Node.js?
Zadania praktyczne w grze
- Układanie w pionie
Uporządkuj etapy Streaming SSR od pierwszego do ostatniego:
- Układanie w poziomie
Ułóż opakowywanie komponentu w ErrorBoundary + Suspense od lewej do prawej:
- Klikanie w kolejności
Ułóż składnię useSuspenseQuery z TanStack React Query: