Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

Streaming SSR i Edge Runtime

8 min czytania
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 tle

renderToPipeableStream - 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:

CechaNode.js RuntimeEdge Runtime
LokalizacjaJeden lub kilka regionówSerwery CDN na całym świecie
Zimny start (cold start)200-500ms (zimna funkcja serverless)1-5ms (izolat V8)
Rozmiar koduDuży limit (np. 250 MB na Vercel)1-4 MB po kompresji (Vercel, zależnie od planu)
APIPełne Node.js APIWeb APIs, bez systemu plików i modułów Node.js
StreamingrenderToPipeableStreamrenderToReadableStream
Bazy danychDowolneSterowniki 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:

  1. Streaming SSR - wysyła HTML fragmentami, użytkownik widzi stronę natychmiast
  2. renderToPipeableStream - API Node.js do streamingu
  3. renderToReadableStream - API Web Streams (Edge Runtime, Cloudflare Workers, Deno)
  4. Suspense boundaries - definiują granice streamingu, a Error Boundary nad nimi łapie błędy
  5. Edge Runtime - kod blisko użytkownika i szybki zimny start, ale w Next.js 16 domyślnym i zalecanym środowiskiem jest Node.js
  6. 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. 1. Jaka jest główna różnica między renderToPipeableStream a renderToReadableStream?

  2. 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:

Przydatne artykuły