Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
React 19.2 - Partial Pre-rendering (PPR)
W tej lekcji9
Strona misji ma stały nagłówek i menu, ale listę aktywnych misji trzeba pobrać przy każdym żądaniu. Statyczne generowanie (SSG) poda szkielet błyskawicznie, ale ze starymi danymi, a SSR poda świeże dane, lecz użytkownik czeka na najwolniejsze zapytanie. Partial Pre-rendering (PPR) łączy oba światy na jednej stronie, jak statek z trasą obliczoną przed startem, który koryguje kurs, gdy dotrą nowe dane.
Czym jest Partial Pre-rendering?
PPR pre-renderuje statyczne części strony podczas budowania, dynamiczne renderuje w czasie żądania i strumieniuje je do klienta w miarę gotowości. React 19.2 dodał do react-dom niskopoziomowe API: prerender zwraca gotową powłokę (prelude) i odłożony stan (postponed), a resume później dokańcza renderowanie. Next.js buduje na nim wygodną warstwę. Tak wygląda podział strony:
1┌─────────────────────────────────────────┐
2│ Strona WWW │
3├─────────────────────────────────────────┤
4│ ┌─────────────────────────────────┐ │
5│ │ NAGŁÓWEK (pre-rendered) │ │ ← Natychmiastowo
6│ └─────────────────────────────────┘ │
7│ ┌─────────────────────────────────┐ │
8│ │ SIDEBAR (pre-rendered) │ │ ← Natychmiastowo
9│ └─────────────────────────────────┘ │
10│ ┌─────────────────────────────────┐ │
11│ │ CONTENT (Suspense boundary) │ │ ← Streaming
12│ │ ┌───────────────────────┐ │ │
13│ │ │ Loading... │ │ │
14│ │ │ → Dynamic content │ │ │
15│ │ └───────────────────────┘ │ │
16│ └─────────────────────────────────┘ │
17└─────────────────────────────────────────┘Nagłówek i sidebar trafiają do przeglądarki od razu, a fallback w miejscu treści po chwili zamienia się w dane. Granicę wyznacza <Suspense>.
PPR w samym Reakcie: prerender i resume
Next.js nie wymyśla tu własnego mechanizmu, tylko korzysta z API dodanego w React 19.2. Na etapie budowania prerender z react-dom/static renderuje aplikację, a gdy przerwiesz go sygnałem z AbortController, granice Suspense z niegotowymi danymi trafiają do powłoki jako fallback, a ich stan ląduje w obiekcie postponed. Przy żądaniu resume z react-dom/server dokańcza renderowanie właśnie od tego miejsca:
1// build.js - etap budowania
2import { prerender } from 'react-dom/static';
3
4const controller = new AbortController();
5setTimeout(() => controller.abort(), 100); // Po chwili przestań czekać na dane z żądania
6
7const { prelude, postponed } = await prerender(<MissionsPage />, {
8 signal: controller.signal,
9});
10await saveShell(prelude); // Statyczna powłoka HTML, np. do CDN
11await savePostponedState(postponed); // Stan odłożonych granic Suspense
12
13// server.js - przy każdym żądaniu
14import { resume } from 'react-dom/server';
15
16const postponedState = await loadPostponedState();
17const stream = await resume(<MissionsPage />, postponedState);
18// Przeglądarka dostaje najpierw powłokę z CDN, a potem ten strumień z resztą HTMLprelude to gotowy HTML powłoki, a postponed to zwykły obiekt JSON, który zapiszesz i odczytasz przy żądaniu. Jeśli wszystko zdążyło się wyrenderować, postponed ma wartość null. Do generowania stron statycznych służy jeszcze resumeAndPrerender z react-dom/static, a w Node.js warianty dla strumieni Node: resumeToPipeableStream i prerenderToNodeStream.
Jak działa PPR?
Konfiguracja w Next.js
W Next.js 15 PPR było eksperymentalne i włączało się flagą experimental.ppr. Next.js 16 ją usunął, a PPR jest domyślnym zachowaniem po włączeniu Cache Components:
1// next.config.js (Next.js 16+)
2module.exports = {
3 cacheComponents: true, // Włącza Cache Components, a z nimi Partial Pre-rendering
4};Z Cache Components dane są domyślnie dynamiczne, a do powłoki trafia to, co nie czyta danych z żądania albo jest oznaczone dyrektywą 'use cache'. Dane z żądania to cookies(), headers(), searchParams, params bez generateStaticParams, zapytania bez cache i wywołanie connection(). Komponent, który ich używa, musi leżeć wewnątrz <Suspense>, inaczej Next.js zgłosi błąd prerenderingu. Cache Components wymaga środowiska Node.js, więc trasy z przestarzałym eksportem runtime = 'edge' trzeba przenieść.
Struktura strony z PPR
Strona z PPR to zwykły komponent, w którym część dynamiczną owijasz w Suspense:
1// app/missions/page.jsx
2import { Suspense } from 'react';
3import MissionHeader from './MissionHeader';
4import MissionSidebar from './MissionSidebar';
5import MissionList from './MissionList';
6import MissionListSkeleton from './MissionListSkeleton';
7
8// Ta strona będzie częściowo pre-renderowana
9export default function MissionsPage() {
10 return (
11 <div className="missions-layout">
12 {/* Te komponenty są statyczne - pre-rendered */}
13 <MissionHeader />
14 <MissionSidebar />
15
16 {/* Ten komponent jest dynamiczny - streaming */}
17 <Suspense fallback={<MissionListSkeleton />}>
18 <MissionList />
19 </Suspense>
20 </div>
21 );
22}MissionHeader i MissionSidebar trafią do statycznej powłoki, a MissionList wyrenderuje się przy żądaniu. Sposób pisania komponentów się nie zmienia.
Komponenty statyczne (pre-rendered)
Komponenty statyczne nie pobierają danych ani nie czytają niczego z żądania, więc serwer renderuje je raz, podczas budowania:
1// MissionHeader.jsx - statyczny komponent
2export default function MissionHeader() {
3 return (
4 <header className="mission-header">
5 <h1>Centrum Kontroli Misji</h1>
6 <nav>
7 <a href="/missions">Wszystkie misje</a>
8 <a href="/missions/active">Aktywne</a>
9 <a href="/missions/completed">Ukończone</a>
10 </nav>
11 </header>
12 );
13}
14
15// MissionSidebar.jsx - statyczny komponent
16export default function MissionSidebar() {
17 return (
18 <aside className="mission-sidebar">
19 <h2>Filtruj misje</h2>
20 <ul>
21 <li>Misje eksploracyjne</li>
22 <li>Misje badawcze</li>
23 <li>Misje ratunkowe</li>
24 </ul>
25 </aside>
26 );
27}Ich HTML jest taki sam dla wszystkich, więc może leżeć w CDN.
Komponenty dynamiczne (streaming)
Komponent dynamiczny to asynchroniczny Server Component, który pobiera świeże dane przy każdym żądaniu:
1// MissionList.jsx - dynamiczny Server Component
2async function getMissions() {
3 // Bez 'use cache' fetch niczego nie zapamiętuje: dane pobierane przy każdym żądaniu
4 const res = await fetch('https://api.space-missions.com/missions');
5 return res.json();
6}
7
8export default async function MissionList() {
9 const missions = await getMissions();
10
11 return (
12 <div className="mission-list">
13 {missions.map(mission => (
14 <MissionCard key={mission.id} mission={mission} />
15 ))}
16 </div>
17 );
18}
19
20// MissionListSkeleton.jsx - fallback podczas ładowania
21export default function MissionListSkeleton() {
22 return (
23 <div className="mission-list skeleton">
24 {[1, 2, 3].map(i => (
25 <div key={i} className="mission-card-skeleton">
26 <div className="skeleton-title" />
27 <div className="skeleton-text" />
28 <div className="skeleton-text short" />
29 </div>
30 ))}
31 </div>
32 );
33}Z Cache Components fetch bez dyrektywy 'use cache' niczego nie zapamiętuje, więc lista pobiera się przy każdym żądaniu, a do nadejścia danych widać MissionListSkeleton. Szkielet powinien mieć wymiary listy, żeby treść nie skakała.
Zalety PPR
Szybszy Time To First Byte (TTFB)
Porównaj oś czasu żądania bez PPR i z PPR:
1Bez PPR:
2Request → Render całości → Response
3 [==========] 800ms
4
5Z PPR:
6Request → Pre-rendered shell → Response (instant)
7 Dynamic parts → Stream
8 [==] 50ms (shell)
9 [======] 750ms (dynamic, streaming)Liczby na schemacie są poglądowe. Powłoka wychodzi od razu, więc pierwszy bajt nie czeka na najwolniejsze zapytanie.
Lepsze Core Web Vitals
Na dashboardzie każdy widżet z danymi dostaje własną granicę Suspense:
1// Strona z wieloma dynamicznymi sekcjami
2export default function DashboardPage() {
3 return (
4 <div className="dashboard">
5 {/* Statyczny layout - natychmiastowy LCP */}
6 <DashboardHeader />
7 <DashboardNav />
8
9 <main className="dashboard-content">
10 {/* Dynamiczne widgety - streaming */}
11 <Suspense fallback={<WidgetSkeleton />}>
12 <ActiveMissionsWidget />
13 </Suspense>
14
15 <Suspense fallback={<WidgetSkeleton />}>
16 <CrewStatusWidget />
17 </Suspense>
18
19 <Suspense fallback={<WidgetSkeleton />}>
20 <ResourcesWidget />
21 </Suspense>
22 </main>
23 </div>
24 );
25}Statyczny nagłówek przyspiesza FCP i LCP, a widżety ładują się niezależnie. Ustal budżet wydajności, czyli limity metryk, jak rozmiar paczki czy czas ładowania, i sprawdzaj go przy każdym wdrożeniu.
Hierarchiczny Suspense
Granice można zagnieżdżać, żeby zależne sekcje pojawiały się warstwami:
1export default function MissionDetailsPage({ params }) {
2 // params to obietnica: strona na nią nie czeka, tylko przekazuje ją dalej
3 return (
4 <div className="mission-details">
5 {/* Poziom 1: Główne dane misji */}
6 <Suspense fallback={<MissionHeaderSkeleton />}>
7 <MissionHeader params={params} />
8
9 {/* Poziom 2: Załoga (zależy od danych misji) */}
10 <Suspense fallback={<CrewListSkeleton />}>
11 <CrewList params={params} />
12 </Suspense>
13
14 {/* Poziom 2: Oś czasu */}
15 <Suspense fallback={<TimelineSkeleton />}>
16 <MissionTimeline params={params} />
17 </Suspense>
18 </Suspense>
19 </div>
20 );
21}
22
23async function CrewList({ params }) {
24 const { id } = await params; // Czekamy dopiero wewnątrz granicy Suspense
25 const crew = await getCrew(id);
26
27 return (
28 <ul>
29 {crew.map(member => (
30 <li key={member.id}>{member.name}</li>
31 ))}
32 </ul>
33 );
34}Załoga i oś czasu pojawią się po nagłówku misji, każda osobno. W Next.js 15 i 16 params jest obietnicą, dlatego strona nie czeka na nią sama, tylko przekazuje ją dzieciom, a id odczytują dopiero komponenty wewnątrz Suspense. Dzięki temu układ strony i fallbacki nadal trafiają do statycznej powłoki.
Praktyczny przykład - Dashboard kosmiczny
Pełny dashboard łączy część statyczną z dwiema sekcjami dynamicznymi:
1// app/dashboard/page.jsx
2import { Suspense } from 'react';
3import { connection } from 'next/server';
4
5// Statyczny układ - trafia do powłoki
6function DashboardLayout({ children }) {
7 return (
8 <div className="space-dashboard">
9 <header className="dashboard-header">
10 <h1>Galaktyczne Centrum Kontroli</h1>
11 <Suspense fallback={<time>...</time>}>
12 <CurrentDate />
13 </Suspense>
14 </header>
15 {children}
16 </div>
17 );
18}
19
20// Data z chwili żądania, a nie z chwili budowania
21async function CurrentDate() {
22 await connection(); // Od tego miejsca renderuj przy żądaniu
23 return <time>{new Date().toLocaleDateString('pl-PL')}</time>;
24}
25
26// Dynamiczny komponent - pobiera dane w czasie rzeczywistym
27async function LiveMissionFeed() {
28 // Bez 'use cache' każde żądanie pobiera świeże dane
29 const missions = await fetch('https://api.space/missions/live').then(r => r.json());
30
31 return (
32 <section className="live-feed">
33 <h2>Aktywne misje</h2>
34 {missions.map(m => (
35 <article key={m.id} className="mission-item">
36 <h3>{m.name}</h3>
37 <p>Status: {m.status}</p>
38 <p>Postęp: {m.progress}%</p>
39 </article>
40 ))}
41 </section>
42 );
43}
44
45// Dynamiczny komponent - powiadomienia
46async function NotificationsPanel() {
47 const notifications = await fetch('https://api.space/notifications').then(r => r.json());
48
49 return (
50 <aside className="notifications">
51 <h2>Powiadomienia</h2>
52 {notifications.map(n => (
53 <div key={n.id} className={`notification ${n.priority}`}>
54 {n.message}
55 </div>
56 ))}
57 </aside>
58 );
59}
60
61// Statyczny komponent - statystyki ogólne
62function GeneralStats() {
63 return (
64 <div className="stats-panel">
65 <h2>Statystyki floty</h2>
66 <ul>
67 <li>Aktywne statki: 47</li>
68 <li>Ukończone misje: 1,234</li>
69 <li>Członkowie załóg: 892</li>
70 </ul>
71 </div>
72 );
73}
74
75// Główna strona z PPR
76export default function DashboardPage() {
77 return (
78 <DashboardLayout>
79 {/* Statyczne - pre-rendered */}
80 <GeneralStats />
81
82 <div className="dynamic-content">
83 {/* Dynamiczne - streaming */}
84 <Suspense fallback={<div className="skeleton feed-skeleton" />}>
85 <LiveMissionFeed />
86 </Suspense>
87
88 <Suspense fallback={<div className="skeleton notif-skeleton" />}>
89 <NotificationsPanel />
90 </Suspense>
91 </div>
92 </DashboardLayout>
93 );
94}GeneralStats należy do powłoki, a LiveMissionFeed i NotificationsPanel strumieniują się niezależnie. Datę obsługuje osobny komponent CurrentDate: zwykłe new Date() w powłoce zamroziłoby datę z chwili budowania, dlatego Cache Components zgłasza wtedy błąd prerenderingu. await connection() przesuwa renderowanie tego jednego komponentu na czas żądania, a Suspense pokazuje w tym czasie fallback. Datę możesz też wyświetlić w komponencie klienckim.
Kontrolowanie zachowania cache
Z Cache Components o tym, co trafia do powłoki, decydują dyrektywy i funkcje w kodzie komponentów. Dane wspólne dla wszystkich oznaczasz 'use cache' z cacheLife i cacheTag, a dane konkretnego użytkownika czytasz wewnątrz Suspense:
1// app/missions/page.jsx
2import { Suspense } from 'react';
3import { cookies } from 'next/headers';
4import { cacheLife, cacheTag } from 'next/cache';
5
6// Wspólne dla wszystkich - trafia do statycznej powłoki
7async function FleetStats() {
8 'use cache';
9 cacheLife('hours'); // Odświeżanie co godzinę
10 cacheTag('fleet'); // revalidateTag('fleet', 'max') odświeży dane na żądanie
11
12 const stats = await fetch('https://api.space/fleet/stats').then(r => r.json());
13 return <p>Aktywne statki: {stats.activeShips}</p>;
14}
15
16// Zależy od ciasteczka - renderowane przy każdym żądaniu
17async function PilotGreeting() {
18 const pilot = (await cookies()).get('pilot')?.value ?? 'astronauto';
19 return <p>Witaj, {pilot}!</p>;
20}
21
22export default function MissionsPage() {
23 return (
24 <main>
25 <h1>Centrum Kontroli Misji</h1>
26 <FleetStats />
27 <Suspense fallback={<p>Łączenie z pilotem...</p>}>
28 <PilotGreeting />
29 </Suspense>
30 </main>
31 );
32}Nagłówek i FleetStats trafią do powłoki, bo nie zależą od żądania, a powitanie każdy użytkownik dostanie osobno. Bez Cache Components o renderowaniu decydowały eksporty konfiguracji trasy, jak dynamic = 'force-static', dynamic = 'force-dynamic' czy revalidate. Po włączeniu cacheComponents takie eksporty powodują błąd: zamiast force-static oznaczasz dane dyrektywą 'use cache', zamiast revalidate ustawiasz cacheLife, a force-dynamic jest zbędne, bo bez 'use cache' wszystko jest dynamiczne.
Kiedy używać PPR?
| Scenariusz | Rekomendacja |
|---|---|
| Landing page z dynamicznym hero | PPR |
| Dashboard z danymi na żywo | PPR |
| E-commerce z cenami/dostępnością | PPR |
| Blog ze statyczną treścią | Pełny SSG |
| Aplikacja SPA bez SEO | Client-side |
| Strona z personalizacją | PPR |
Porównanie strategii renderowania
Wybór strategii to część planowania architektury: najpierw analizujesz wymagania, jak świeżość danych, SEO i personalizacja, a potem dobierasz renderowanie:
1┌────────────────┬─────────────────────────────────────────┐
2│ Strategia │ Charakterystyka │
3├────────────────┼─────────────────────────────────────────┤
4│ SSG │ Całość pre-rendered, brak dynamiki │
5│ SSR │ Całość renderowana per-request │
6│ ISR │ Pre-rendered z revalidation │
7│ CSR │ Całość renderowana na kliencie │
8│ PPR │ Statyczny shell + dynamiczny streaming │
9└────────────────┴─────────────────────────────────────────┘
10
11SSG: [████████████] → Client (instant, but stale)
12SSR: [............] → [████████████] → Client (slower)
13PPR: [████] → [░░░░░░░░] → Client (fast shell + streaming)PPR łączy SSG i SSR na jednej stronie, nie zastępując ich. W przeglądarce każda strategia kończy się podobnie: najpierw widać HTML (FCP), potem przychodzi JavaScript, a React hydratuje stronę, dołączając obsługę zdarzeń, więc onClick zadziała dopiero po hydracji. Identyfikatory muszą się zgadzać na serwerze i w przeglądarce, więc generuj je przez useId, nie Math.random(). Streaming opiera się na współbieżnym Reakcie z poprzedniej lokacji: createRoot, automatycznym grupowaniu aktualizacji, startTransition i useDeferredValue.
Debugging PPR
Najpewniejszym testem podziału jest produkcyjny build. next build wypisuje tabelę tras, a symbol przy każdej trasie mówi, jak będzie serwowana:
1Route (app)
2┌ ○ /
3├ ○ /about
4└ ◐ /missions
5
6○ (Static) prerendered as static content
7◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content○ oznacza stronę w pełni statyczną, a ◐ stronę ze statyczną powłoką i dynamicznymi fragmentami dosyłanymi strumieniowo. Jeśli komponent czyta dane z żądania albo pobiera dane bez cache poza <Suspense>, build zatrzyma się z błędem i podpowie trzy wyjścia: dodać granicę Suspense, zapamiętać dane przez 'use cache' albo świadomie pozwolić trasie blokować renderowanie. Dokładny ślad stosu da next build --debug-prerender, a w trybie deweloperskim te same problemy pokazuje nakładka Next.js. Drzewo komponentów, propsy i stan podejrzysz w React DevTools, a od React 19.2 Chrome DevTools pokazują też ścieżki wydajności Reacta (Scheduler i Components).
Podsumowanie
- Najlepsze z dwóch światów - szybkość statycznego + świeżość dynamicznego
- Automatyczny podział - Next.js wyznacza powłokę na podstawie granic Suspense i danych
- Streaming HTML - użytkownik widzi treść stopniowo
- Suspense boundaries - kontrolujesz granularność dynamicznych części
- Lepsze Core Web Vitals - szczególnie LCP i FCP
Moja rada: zacznij od statycznej powłoki i przesuwaj granice Suspense jak najniżej, do najmniejszego komponentu, który naprawdę potrzebuje danych z żądania.
Pamiętaj: PPR to hybrydowy napęd statku - statyczna powłoka startuje natychmiast, a dynamiczne moduły dołączają w locie. W przykładzie poniżej kliknij "Nowe żądanie": powłoka z menu i filtrami zostaje na miejscu, a sekcje w granicach Suspense, także gotowy już widżet zasobów, dochodzą od nowa z danymi z kolejnego żądania.
Kod do tej lekcji: App.jsx
1import { Suspense, use, useState } from 'react';
2
3// === PARTIAL PRE-RENDERING (PPR) - symulacja w przeglądarce ===
4// Statyczna powłoka (nagłówek, menu, filtry) jest gotowa od razu, jak HTML zbudowany
5// podczas "next build". Sekcje w granicach Suspense dochodzą strumieniowo przy każdym
6// żądaniu. Przycisk "Nowe żądanie" pokazuje, że powłoka zostaje, a dane są świeże.
7
8const BUILD_TIME = new Date().toLocaleTimeString('pl-PL'); // Moment "budowania" powłoki
9
10const requests = new Map();
11
12// Dane dynamiczne: jedna obietnica na sekcję w danym żądaniu
13function fetchForRequest(section, requestId, delay, makeData) {
14 const key = section + ':' + requestId;
15 if (!requests.has(key)) {
16 requests.set(key, new Promise(resolve => setTimeout(() => {
17 resolve({ data: makeData(requestId), time: new Date().toLocaleTimeString('pl-PL') });
18 }, delay)));
19 }
20 return requests.get(key);
21}
22
23const vary = (base, requestId, spread) => Math.min(100, base + ((requestId * 7) % spread));
24
25// === STATYCZNA POWŁOKA (prerender) ===
26
27function MissionHeader() {
28 return (
29 <header style={styles.header}>
30 <h1 style={styles.headerTitle}>Centrum Kontroli Misji</h1>
31 <nav style={styles.nav}>
32 <a href="#all" style={styles.navLink}>Wszystkie misje</a>
33 <a href="#active" style={{ ...styles.navLink, ...styles.navLinkActive }}>Aktywne</a>
34 <a href="#completed" style={styles.navLink}>Ukończone</a>
35 </nav>
36 </header>
37 );
38}
39
40function MissionSidebar() {
41 const filters = ['Eksploracyjne', 'Badawcze', 'Ratunkowe', 'Transportowe'];
42
43 return (
44 <aside style={styles.sidebar}>
45 <h3 style={{ color: '#00d4ff', marginTop: 0, fontSize: '16px' }}>Filtruj misje</h3>
46 {filters.map(filter => (
47 <label key={filter} style={styles.filterLabel}>
48 <input type="checkbox" style={{ accentColor: '#00d4ff' }} />
49 <span>{filter}</span>
50 </label>
51 ))}
52 <div style={styles.staticBadge}>POWŁOKA STATYCZNA</div>
53 <p style={styles.meta}>Zbudowano o {BUILD_TIME}</p>
54 </aside>
55 );
56}
57
58// === DYNAMICZNE SEKCJE (streaming w granicach Suspense) ===
59
60function StreamedBadge({ requestId, time }) {
61 return (
62 <span style={styles.dynamicBadge}>STREAMING - żądanie #{requestId}, {time}</span>
63 );
64}
65
66function ActiveMissionsWidget({ requestId }) {
67 const { data, time } = use(fetchForRequest('missions', requestId, 1500, id => [
68 { id: 1, name: 'Eksploracja Mgławicy Oriona', progress: vary(70, id, 25) },
69 { id: 2, name: 'Sonda Ciemnej Materii', progress: vary(40, id, 30) },
70 { id: 3, name: 'Patrol Pasa Asteroid', progress: vary(85, id, 15) },
71 ]));
72
73 return (
74 <section style={styles.panel}>
75 <div style={styles.panelHeader}>
76 <h3 style={styles.panelTitle}>Aktywne misje</h3>
77 <StreamedBadge requestId={requestId} time={time} />
78 </div>
79 <div style={styles.missionGrid}>
80 {data.map(m => (
81 <div key={m.id} style={styles.card}>
82 <h4 style={{ margin: '0 0 8px', fontSize: '14px' }}>{m.name}</h4>
83 <div style={styles.progressBar}>
84 <div style={{ ...styles.progressFill, width: m.progress + '%' }} />
85 </div>
86 <span style={{ color: '#00ff88', fontSize: '12px' }}>{m.progress}%</span>
87 </div>
88 ))}
89 </div>
90 </section>
91 );
92}
93
94function CrewStatusWidget({ requestId }) {
95 const { data, time } = use(fetchForRequest('crew', requestId, 2200, id => [
96 { name: 'Komandor Nova', location: 'Mostek', active: true },
97 { name: 'Astro', location: 'Nawigacja', active: true },
98 { name: 'Ra', location: 'Laboratorium', active: id % 2 === 0 },
99 ]));
100
101 return (
102 <section style={styles.panel}>
103 <div style={styles.panelHeader}>
104 <h3 style={styles.panelTitle}>Status załogi</h3>
105 <StreamedBadge requestId={requestId} time={time} />
106 </div>
107 {data.map(c => (
108 <div key={c.name} style={styles.row}>
109 <span>{c.name}</span>
110 <span style={styles.meta}>{c.location}</span>
111 <span style={{ ...styles.dot, background: c.active ? '#00ff88' : '#ffa500' }} />
112 </div>
113 ))}
114 </section>
115 );
116}
117
118function ResourcesWidget({ requestId }) {
119 const { data, time } = use(fetchForRequest('resources', requestId, 3000, id => [
120 { name: 'Paliwo', level: vary(60, id, 30) },
121 { name: 'Tlen', level: vary(85, id, 15) },
122 { name: 'Energia', level: vary(70, id, 25) },
123 { name: 'Woda', level: vary(50, id, 35) },
124 { name: 'Żywność', level: vary(40, id, 40) },
125 ]));
126
127 return (
128 <section style={styles.panel}>
129 <div style={styles.panelHeader}>
130 <h3 style={styles.panelTitle}>Zasoby statku</h3>
131 <StreamedBadge requestId={requestId} time={time} />
132 </div>
133 {data.map(r => (
134 <div key={r.name} style={styles.resource}>
135 <span style={{ width: '70px' }}>{r.name}</span>
136 <div style={styles.progressBar}>
137 <div style={{ ...styles.progressFill, width: r.level + '%', background: r.level < 50 ? '#ffa500' : '#00ff88' }} />
138 </div>
139 <span style={{ width: '40px', textAlign: 'right' }}>{r.level}%</span>
140 </div>
141 ))}
142 </section>
143 );
144}
145
146function TelemetryReport({ requestId }) {
147 const { time } = use(fetchForRequest('telemetry', requestId, 3500, () => null));
148
149 return (
150 <section style={{ ...styles.panel, textAlign: 'center' }}>
151 <StreamedBadge requestId={requestId} time={time} />
152 <p style={{ color: '#64ffda', margin: '8px 0 0' }}>Analiza telemetrii zakończona - wszystkie systemy sprawne.</p>
153 </section>
154 );
155}
156
157function SkeletonCard({ lines = 3 }) {
158 return (
159 <div style={styles.panel}>
160 <div style={{ ...styles.skeletonLine, width: '50%', height: '16px' }} />
161 {Array.from({ length: lines }).map((_, i) => (
162 <div key={i} style={{ ...styles.skeletonLine, width: 85 - i * 12 + '%' }} />
163 ))}
164 </div>
165 );
166}
167
168export default function App() {
169 const [requestId, setRequestId] = useState(1);
170
171 return (
172 <div style={styles.container}>
173 <MissionHeader />
174
175 <div style={styles.layout}>
176 <MissionSidebar />
177
178 <main key={requestId} style={styles.main}>
179 <div style={styles.toolbar}>
180 <button style={styles.button} onClick={() => setRequestId(id => id + 1)}>Nowe żądanie</button>
181 <span style={styles.meta}>Powłoka zostaje na miejscu, dane dochodzą od nowa (żądanie #{requestId})</span>
182 </div>
183
184 <Suspense fallback={<SkeletonCard lines={3} />}>
185 <ActiveMissionsWidget requestId={requestId} />
186 </Suspense>
187
188 <div style={styles.twoColumns}>
189 <Suspense fallback={<SkeletonCard lines={3} />}>
190 <CrewStatusWidget requestId={requestId} />
191 </Suspense>
192 <Suspense fallback={<SkeletonCard lines={5} />}>
193 <ResourcesWidget requestId={requestId} />
194 </Suspense>
195 </div>
196
197 <Suspense fallback={<SkeletonCard lines={1} />}>
198 <TelemetryReport requestId={requestId} />
199 </Suspense>
200 </main>
201 </div>
202 <style>{'@keyframes skeletonPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }'}</style>
203 </div>
204 );
205}
206
207const styles = {
208 container: { minHeight: '100vh', background: 'linear-gradient(135deg, #0d1b2a 0%, #1a1a3e 100%)', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
209 header: { padding: '16px 24px', borderBottom: '1px solid rgba(0,212,255,0.15)', background: 'rgba(0,0,0,0.2)' },
210 headerTitle: { color: '#00d4ff', margin: '0 0 10px', fontSize: '22px' },
211 nav: { display: 'flex', gap: '16px' },
212 navLink: { color: '#8892b0', textDecoration: 'none', fontSize: '14px', paddingBottom: '4px', borderBottom: '2px solid transparent' },
213 navLinkActive: { color: '#00d4ff', borderBottom: '2px solid #00d4ff' },
214 layout: { display: 'grid', gridTemplateColumns: '200px minmax(0, 1fr)' },
215 sidebar: { padding: '16px', borderRight: '1px solid rgba(0,212,255,0.1)', background: 'rgba(0,0,0,0.15)' },
216 filterLabel: { display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', cursor: 'pointer' },
217 staticBadge: { display: 'inline-block', marginTop: '14px', padding: '3px 8px', background: 'rgba(0,212,255,0.15)', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '4px', color: '#00d4ff', fontSize: '10px', fontWeight: 'bold' },
218 main: { padding: '16px', display: 'grid', gap: '12px', alignContent: 'start' },
219 toolbar: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' },
220 button: { padding: '6px 14px', background: '#00d4ff', color: '#0d1b2a', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
221 meta: { color: '#8892b0', fontSize: '12px' },
222 panel: { background: 'rgba(0,0,0,0.3)', borderRadius: '12px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
223 panelHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginBottom: '10px', flexWrap: 'wrap' },
224 panelTitle: { color: '#00d4ff', margin: 0, fontSize: '15px' },
225 dynamicBadge: { display: 'inline-block', padding: '3px 8px', background: 'rgba(0,255,136,0.12)', border: '1px solid rgba(0,255,136,0.3)', borderRadius: '4px', color: '#00ff88', fontSize: '10px', fontWeight: 'bold' },
226 missionGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '10px' },
227 card: { padding: '12px', background: 'rgba(0,0,0,0.3)', borderRadius: '10px', border: '1px solid rgba(0,212,255,0.1)' },
228 progressBar: { flex: 1, height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px', margin: '6px 0', overflow: 'hidden' },
229 progressFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)' },
230 twoColumns: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '12px' },
231 row: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', padding: '8px', background: 'rgba(0,0,0,0.2)', borderRadius: '6px', marginBottom: '4px' },
232 dot: { width: '8px', height: '8px', borderRadius: '50%' },
233 resource: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' },
234 skeletonLine: { height: '12px', background: 'rgba(0,212,255,0.1)', borderRadius: '4px', marginBottom: '8px', animation: 'skeletonPulse 1.5s infinite' },
235};Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Na czym polega Partial Pre-rendering (PPR)?
2. Które narzędzie z ekosystemu React służy do inspekcji drzewa komponentów i ich propsów/stanu w przeglądarce?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Układanie w pionie
Uporządkuj kroki procesu Server-Side Rendering i hydracji:
- Układanie w pionie
Uporządkuj kroki wdrażania funkcji współbieżnych (Concurrent React) w istniejącej aplikacji:
- Klikanie w kolejności
Ułóż deklarację i użycie hooka useId do połączenia label z input:
- Edytor kodu
Dokończ reducer centrum kontroli misji. Akcja launch startuje zaplanowaną misję, jeśli wystarczy paliwa. ___BLANK1___: nowa wartość paliwa to paliwo ze stanu pomniejszone o fuelCost startującej misji. ___BLANK2___: startująca misja staje się nowym obiektem ze wszystkimi polami starej i statusem 'w locie' (spread, bez zmieniania starego obiektu). ___BLANK3___: App tworzy stan i dispatch przez useReducer z missionReducer i initialState. Reducer nie może mutować stanu: zawsze zwraca nowe obiekty i tablice.
- Klikanie w kolejności
Ułóż kod użycia startTransition do oznaczenia aktualizacji jako niepilnej:
- Edytor kodu
Spraw, żeby mapa nawigacyjna reagowała płynnie mimo katalogu 3000 gwiazd. ___BLANK1___: deferredQuery to odroczona wersja query z useDeferredValue - pole tekstowe pokazuje wpisany tekst od razu, a lista filtruje się w tle (do tego czasu jest przygaszona, bo isStale jest true). ___BLANK2___: weź isPending i startTransition z hooka useTransition. ___BLANK3___: selectTab zmienia zakładkę przez startTransition(() => setTab(next)), więc w trakcie przełączania pojawia się komunikat z role="status".
- Układanie w pionie
Uporządkuj obsługę zdarzeń w React:
- Edytor kodu
Dokończ panel Core Web Vitals obserwatorium. Progi (web.dev): LCP dobrze do 2500 ms, wymaga poprawy do 4000 ms; INP dobrze do 200 ms, wymaga poprawy do 500 ms; CLS dobrze do 0.1, wymaga poprawy do 0.25; powyżej jest słabo (poor). ___BLANK1___: rateMetric zwraca needs-improvement, gdy wartość nie przekracza progu poor danego wskaźnika (granica też się liczy). ___BLANK2___: kolor ramki karty to kolor oceny z obiektu COLORS. ___BLANK3___: summarize zwiększa o 1 licznik kategorii, do której trafił wskaźnik. Dla pomiarów z sondy podsumowanie to: 1 dobrze, 1 wymaga poprawy, 1 słabo.
- Klikanie w kolejności
Ułóż komponent, który przez useDeferredValue odracza filtrowanie listy gwiazd i przygasza nieaktualny wynik:
- Edytor kodu
Zbuduj stronę misji w architekturze App Routera: MissionsPage to asynchroniczny Server Component, a MissionFilter to Client Component ze stanem. ___BLANK1___: MissionsPage czeka na dane z getMissions() (await). ___BLANK2___: przekaż listę misji do MissionFilter przez props missions. ___BLANK3___: gdy zaznaczone jest Tylko aktywne, filtr zostawia misje o statusie 'aktywna'. Server Component nie ma stanu ani obsługi zdarzeń - to wszystko jest w komponencie klienckim. Podgląd w edytorze udaje Next.js, więc strona pojawi się po ok. pół sekundy.