From 50a79f2f766922f8544eb8b0c26a3dc0921a4e6c Mon Sep 17 00:00:00 2001 From: Thomas Hart Date: Sun, 19 Jul 2026 18:50:08 +0000 Subject: [PATCH] feat: add server components streaming route with suspense boundaries --- README.md | 13 +++++ app/page.tsx | 3 +- app/streaming/cards.tsx | 66 ++++++++++++++++++++++ app/streaming/loading.tsx | 16 ++++++ app/streaming/page.tsx | 48 ++++++++++++++++ src/streaming/data.ts | 90 ++++++++++++++++++++++++++++++ src/streaming/loader.ts | 27 +++++++++ test/data.test.ts | 59 ++++++++++++++++++++ test/loader.test.ts | 62 ++++++++++++++++++++ test/streaming-components.test.tsx | 43 ++++++++++++++ 10 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 app/streaming/cards.tsx create mode 100644 app/streaming/loading.tsx create mode 100644 app/streaming/page.tsx create mode 100644 src/streaming/data.ts create mode 100644 src/streaming/loader.ts create mode 100644 test/data.test.ts create mode 100644 test/loader.test.ts create mode 100644 test/streaming-components.test.tsx diff --git a/README.md b/README.md index c721694..8cb27ab 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b - React Server Components: server-first rendering, less client JavaScript - Streaming SSR with Suspense boundaries and progressive hydration - Suspense-based data fetching instead of scattered loading flags +- Request-scoped memoization for fetch deduplication (the `React.cache()` pattern) +- Out-of-order streaming: boundaries flush by resolution time, not tree order - App Router file conventions (`layout`, `page`, `loading`, `error`) - Type-safe design tokens as a single origin for color, spacing, and type scale - Core Web Vitals: LCP, CLS, and INP tracked against a budget @@ -22,6 +24,7 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b - Scaffold: Next.js 15 App Router, React 19, strict TypeScript, Vitest + Testing Library, GitHub Actions CI, and a design-token stylesheet. The home route lists the planned concepts using an accessible `ConceptCard` component. - Design tokens: a typed token tree in `src/tokens` that generates the app's CSS custom properties. The root layout injects the generated `:root` rule at render time, so the token module is the runtime source and there is no second copy to drift. See the `/tokens` route. +- Server Components + streaming: the `/streaming` route is a Server Component whose three cards are each their own async Server Component behind a Suspense boundary. The shell flushes first, then each card streams in as its own fetch settles, fastest first. Data comes from a small simulated layer built on a request-scoped memoizing loader (the `React.cache()` pattern), so components that read the same key during one render share a single fetch and a rejection is cached the same way. Tests cover the loader dedup, out-of-order settle order, and the async components rendered to static markup. See the `/streaming` route. ## Stack @@ -51,6 +54,16 @@ import { ConceptCard } from '@/components/concept-card' /> ``` +Each streaming card is an async Server Component reading from a request-scoped store, wrapped in its own boundary so it streams independently: + +```tsx +const store = createDataStore() // one per request, so the loaders memoize per render + +}> + // async () =>
…
+
+``` + Styles reference tokens through a typed helper, so a bad name fails to compile: ```tsx diff --git a/app/page.tsx b/app/page.tsx index 9c27f04..be835e7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,7 +11,8 @@ const CONCEPTS: readonly Concept[] = [ { title: 'Server Components & streaming', description: 'Render on the server, stream HTML as it resolves, and ship less JavaScript to the client.', - status: 'planned' + status: 'done', + href: '/streaming' }, { title: 'Suspense data fetching', diff --git a/app/streaming/cards.tsx b/app/streaming/cards.tsx new file mode 100644 index 0000000..ce3efc0 --- /dev/null +++ b/app/streaming/cards.tsx @@ -0,0 +1,66 @@ +import { type DataStore } from '@/streaming/data' +import { token } from '@/tokens' + +const cardStyle: React.CSSProperties = { + padding: token('space', '6'), + border: '1px solid var(--color-border)', + borderRadius: token('radius', 'md'), + background: 'var(--color-surface)' +} + +export async function ProfileCard({ store, id }: { store: DataStore; id: string }) { + const profile = await store.profile(id) + return ( +
+

{profile.name}

+

{profile.role}

+
+ ) +} + +export async function Recommendations({ store }: { store: DataStore }) { + const items = await store.recommendations() + return ( +
+

Recommended reading

+ +
+ ) +} + +export async function ActivityFeed({ store, id }: { store: DataStore; id: string }) { + const feed = await store.activity(id) + return ( +
+

Recent activity

+
    + {feed.items.map((line) => ( +
  1. {line}
  2. + ))} +
+
+ ) +} + +export function Skeleton({ label, lines }: { label: string; lines: number }) { + return ( +
+ {Array.from({ length: lines }, (_, i) => ( + + ))} +
+ ) +} diff --git a/app/streaming/loading.tsx b/app/streaming/loading.tsx new file mode 100644 index 0000000..f192f2c --- /dev/null +++ b/app/streaming/loading.tsx @@ -0,0 +1,16 @@ +import { token } from '@/tokens' + +// Shown while the route segment itself is still resolving on the server. The +// per-card Suspense fallbacks in page.tsx take over once the shell streams in. +export default function Loading() { + return ( +
+ Loading… +
+ ) +} diff --git a/app/streaming/page.tsx b/app/streaming/page.tsx new file mode 100644 index 0000000..b2bcad2 --- /dev/null +++ b/app/streaming/page.tsx @@ -0,0 +1,48 @@ +import { Suspense } from 'react' +import { createDataStore } from '@/streaming/data' +import { token } from '@/tokens' +import { ActivityFeed, ProfileCard, Recommendations, Skeleton } from './cards' + +export const metadata = { + title: 'Server Components & streaming - Modern Frontend Lab' +} + +const USER_ID = 'u1' + +export default function StreamingPage() { + // One store per request: the memoizing loaders inside it stay request-scoped. + const store = createDataStore() + + return ( +
+

Server Components & streaming

+

+ The page is a Server Component. Each card below is its own async Server Component behind a Suspense boundary, so the + shell flushes immediately and every card streams in as its data settles, fastest first. None of these cards ship + client JavaScript. +

+ +
+ }> + + + }> + + + }> + + +
+ +
+

Why this shape

+

+ Rendering blocks only where you await. Wrapping each slow read in its own boundary turns one all-or-nothing wait + into three independent flushes, so a fast section never waits behind a slow one. The tradeoff is layout shift: a + fallback that is not the same height as its content moves the page when it swaps, which hurts CLS. Size the + skeletons to match, and do not over-split, since every boundary is a separate round of hydration work. +

+
+
+ ) +} diff --git a/src/streaming/data.ts b/src/streaming/data.ts new file mode 100644 index 0000000..526adcb --- /dev/null +++ b/src/streaming/data.ts @@ -0,0 +1,90 @@ +import { createLoader } from './loader' + +export interface Profile { + id: string + name: string + role: string +} + +export interface ActivityFeed { + items: readonly string[] +} + +export interface Recommendation { + id: string + title: string +} + +export class NotFoundError extends Error { + constructor( + readonly kind: string, + readonly key: string + ) { + super(`${kind} not found: ${key}`) + this.name = 'NotFoundError' + } +} + +// Latencies are staggered so the route visibly streams out of order: the shell +// paints, then each boundary flushes as its own fetch settles, fastest first. +export const LATENCY = { + profile: 120, + activity: 640, + recommendations: 340 +} as const + +const PROFILES: Readonly> = { + u1: { id: 'u1', name: 'Ada Lovelace', role: 'Analyst' }, + u2: { id: 'u2', name: 'Grace Hopper', role: 'Compiler engineer' } +} + +const FEEDS: Readonly> = { + u1: { items: ['Opened issue #204', 'Merged the notes engine', 'Reviewed 3 PRs'] }, + u2: { items: ['Shipped the linker', 'Coined "debugging"'] } +} + +const RECOMMENDATIONS: readonly Recommendation[] = [ + { id: 'r1', title: 'Streaming SSR in depth' }, + { id: 'r2', title: 'When Suspense stops paying off' }, + { id: 'r3', title: 'Request-scoped caching patterns' } +] + +export type Wait = (ms: number) => Promise + +const realWait: Wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export interface DataStore { + profile(id: string): Promise + activity(id: string): Promise + recommendations(): Promise +} + +// One store per render pass keeps the memoization request-scoped: a module-level +// cache would leak one request's data into the next. Tests pass a zero-latency +// `wait` to stay fast and deterministic without fake timers. +export function createDataStore(wait: Wait = realWait): DataStore { + const profiles = createLoader(async (id) => { + await wait(LATENCY.profile) + const found = PROFILES[id] + if (found === undefined) throw new NotFoundError('profile', id) + return found + }) + + const feeds = createLoader(async (id) => { + await wait(LATENCY.activity) + const found = FEEDS[id] + if (found === undefined) throw new NotFoundError('activity', id) + return found + }) + + const recs = createLoader<'all', readonly Recommendation[]>(async () => { + await wait(LATENCY.recommendations) + return RECOMMENDATIONS + }) + + return { + profile: (id) => profiles.load(id), + activity: (id) => feeds.load(id), + recommendations: () => recs.load('all') + } +} diff --git a/src/streaming/loader.ts b/src/streaming/loader.ts new file mode 100644 index 0000000..c010909 --- /dev/null +++ b/src/streaming/loader.ts @@ -0,0 +1,27 @@ +export type Fetcher = (key: K) => Promise + +export interface Loader { + load(key: K): Promise + keys(): readonly K[] +} + +// Request-scoped memoization, the same idea as React's `cache()`: several server +// components that ask for the same key during one render share a single fetch. +// The promise is stored, not the value, so concurrent reads dedupe before the +// first one has even resolved. A rejected promise stays cached too, matching +// `cache()` semantics, so a retry inside the same render fails the same way +// instead of hammering a flaky source. +export function createLoader(fetcher: Fetcher): Loader { + const inflight = new Map>() + + return { + load(key) { + const existing = inflight.get(key) + if (existing !== undefined) return existing + const pending = fetcher(key) + inflight.set(key, pending) + return pending + }, + keys: () => [...inflight.keys()] + } +} diff --git a/test/data.test.ts b/test/data.test.ts new file mode 100644 index 0000000..9969ef0 --- /dev/null +++ b/test/data.test.ts @@ -0,0 +1,59 @@ +import { createDataStore, LATENCY, NotFoundError, type Wait } from '@/streaming/data' + +const instant: Wait = () => Promise.resolve() + +describe('createDataStore', () => { + it('reads seeded records', async () => { + const store = createDataStore(instant) + const profile = await store.profile('u1') + expect(profile.name).toBe('Ada Lovelace') + const feed = await store.activity('u1') + expect(feed.items).toHaveLength(3) + const recs = await store.recommendations() + expect(recs.map((r) => r.id)).toEqual(['r1', 'r2', 'r3']) + }) + + it('dedupes repeated reads of the same key within one store', async () => { + let waits = 0 + const counting: Wait = async () => { + waits += 1 + } + const store = createDataStore(counting) + + await Promise.all([store.profile('u1'), store.profile('u1'), store.recommendations()]) + // Two distinct resources touched, so exactly two underlying reads. + expect(waits).toBe(2) + }) + + it('does not share cache across stores', async () => { + let waits = 0 + const counting: Wait = async () => { + waits += 1 + } + await createDataStore(counting).profile('u1') + await createDataStore(counting).profile('u1') + expect(waits).toBe(2) + }) + + it('throws a typed NotFoundError for an unknown key', async () => { + const store = createDataStore(instant) + await expect(store.profile('nobody')).rejects.toBeInstanceOf(NotFoundError) + await expect(store.activity('nobody')).rejects.toThrow('activity not found: nobody') + }) + + it('settles boundaries fastest-first, which is the streaming order', async () => { + vi.useFakeTimers() + try { + const store = createDataStore() + const order: string[] = [] + store.profile('u1').then(() => order.push('profile')) + store.activity('u1').then(() => order.push('activity')) + store.recommendations().then(() => order.push('recommendations')) + + await vi.advanceTimersByTimeAsync(Math.max(LATENCY.profile, LATENCY.activity, LATENCY.recommendations)) + expect(order).toEqual(['profile', 'recommendations', 'activity']) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/test/loader.test.ts b/test/loader.test.ts new file mode 100644 index 0000000..f3168c6 --- /dev/null +++ b/test/loader.test.ts @@ -0,0 +1,62 @@ +import { createLoader } from '@/streaming/loader' + +describe('createLoader', () => { + it('runs the fetcher once per key and reuses the same promise', () => { + const calls: string[] = [] + const loader = createLoader(async (key) => { + calls.push(key) + return key.length + }) + + const a1 = loader.load('ada') + const a2 = loader.load('ada') + + expect(a1).toBe(a2) + expect(calls).toEqual(['ada']) + }) + + it('dedupes concurrent reads before the first resolves', async () => { + let resolved = 0 + const loader = createLoader( + (key) => + new Promise((resolve) => { + queueMicrotask(() => { + resolved += 1 + resolve(key.length) + }) + }) + ) + + const results = await Promise.all([loader.load('grace'), loader.load('grace'), loader.load('grace')]) + + expect(results).toEqual([5, 5, 5]) + expect(resolved).toBe(1) + }) + + it('keeps distinct keys independent', () => { + const loader = createLoader(async (key) => key.length) + expect(loader.load('a')).not.toBe(loader.load('bb')) + expect(loader.keys()).toEqual(['a', 'bb']) + }) + + it('memoizes a rejection so a retry in the same pass fails identically', async () => { + let attempts = 0 + const loader = createLoader(async () => { + attempts += 1 + throw new Error('flaky') + }) + + const first = loader.load('x') + const second = loader.load('x') + + expect(first).toBe(second) + await expect(first).rejects.toThrow('flaky') + await expect(second).rejects.toThrow('flaky') + expect(attempts).toBe(1) + }) + + it('starts empty', () => { + const loader = createLoader(async (key) => key.length) + expect(loader.keys()).toEqual([]) + }) +}) diff --git a/test/streaming-components.test.tsx b/test/streaming-components.test.tsx new file mode 100644 index 0000000..5cd606b --- /dev/null +++ b/test/streaming-components.test.tsx @@ -0,0 +1,43 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactElement } from 'react' +import { ActivityFeed, ProfileCard, Recommendations } from '../app/streaming/cards' +import { createDataStore, type Wait } from '@/streaming/data' + +const instant: Wait = () => Promise.resolve() + +// An async Server Component is just `async () => ReactElement`. Awaiting it and +// rendering the resolved element proves the component fetched and mapped real +// data, without needing a running Next server. +async function renderServer(node: Promise): Promise { + return renderToStaticMarkup(await node) +} + +describe('streaming server components', () => { + it('ProfileCard renders the fetched profile', async () => { + const store = createDataStore(instant) + const html = await renderServer(ProfileCard({ store, id: 'u1' })) + expect(html).toContain('Ada Lovelace') + expect(html).toContain('Analyst') + }) + + it('Recommendations lists every item', async () => { + const store = createDataStore(instant) + const html = await renderServer(Recommendations({ store })) + expect(html).toContain('Streaming SSR in depth') + expect((html.match(/
  • /g) ?? [])).toHaveLength(3) + }) + + it('ActivityFeed renders the feed in order', async () => { + const store = createDataStore(instant) + const html = await renderServer(ActivityFeed({ store, id: 'u1' })) + const first = html.indexOf('Opened issue #204') + const last = html.indexOf('Reviewed 3 PRs') + expect(first).toBeGreaterThan(-1) + expect(last).toBeGreaterThan(first) + }) + + it('propagates a missing-record error to the boundary', async () => { + const store = createDataStore(instant) + await expect(ProfileCard({ store, id: 'ghost' })).rejects.toThrow('profile not found: ghost') + }) +})