Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

<Suspense fallback={<Skeleton label="profile" lines={2} />}>
<ProfileCard store={store} id="u1" /> // async () => <section>…</section>
</Suspense>
```

Styles reference tokens through a typed helper, so a bad name fails to compile:

```tsx
Expand Down
3 changes: 2 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
66 changes: 66 additions & 0 deletions app/streaming/cards.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Profile" style={cardStyle}>
<h2 style={{ margin: 0, fontSize: token('fontSize', 'lg') }}>{profile.name}</h2>
<p style={{ margin: `${token('space', '2')} 0 0`, color: token('color', 'muted') }}>{profile.role}</p>
</section>
)
}

export async function Recommendations({ store }: { store: DataStore }) {
const items = await store.recommendations()
return (
<section aria-label="Recommendations" style={cardStyle}>
<h2 style={{ margin: 0, fontSize: token('fontSize', 'lg') }}>Recommended reading</h2>
<ul style={{ margin: `${token('space', '3')} 0 0`, paddingLeft: token('space', '6') }}>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</section>
)
}

export async function ActivityFeed({ store, id }: { store: DataStore; id: string }) {
const feed = await store.activity(id)
return (
<section aria-label="Activity" style={cardStyle}>
<h2 style={{ margin: 0, fontSize: token('fontSize', 'lg') }}>Recent activity</h2>
<ol style={{ margin: `${token('space', '3')} 0 0`, paddingLeft: token('space', '6') }}>
{feed.items.map((line) => (
<li key={line}>{line}</li>
))}
</ol>
</section>
)
}

export function Skeleton({ label, lines }: { label: string; lines: number }) {
return (
<div role="status" aria-label={`Loading ${label}`} aria-busy style={{ ...cardStyle, display: 'grid', gap: token('space', '3') }}>
{Array.from({ length: lines }, (_, i) => (
<span
key={i}
aria-hidden
style={{
height: '0.9rem',
width: i === 0 ? '55%' : '85%',
borderRadius: token('radius', 'md'),
background: 'var(--color-border)'
}}
/>
))}
</div>
)
}
16 changes: 16 additions & 0 deletions app/streaming/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main
role="status"
aria-label="Loading Server Components & streaming"
aria-busy
style={{ maxWidth: '52rem', margin: '0 auto', padding: `${token('space', '8')} ${token('space', '4')}`, color: token('color', 'muted') }}
>
Loading…
</main>
)
}
48 changes: 48 additions & 0 deletions app/streaming/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main style={{ maxWidth: '52rem', margin: '0 auto', padding: `${token('space', '8')} ${token('space', '4')}` }}>
<h1>Server Components &amp; streaming</h1>
<p style={{ color: token('color', 'muted'), marginTop: 0 }}>
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.
</p>

<div style={{ display: 'grid', gap: token('space', '4'), marginTop: token('space', '8') }}>
<Suspense fallback={<Skeleton label="profile" lines={2} />}>
<ProfileCard store={store} id={USER_ID} />
</Suspense>
<Suspense fallback={<Skeleton label="recommendations" lines={3} />}>
<Recommendations store={store} />
</Suspense>
<Suspense fallback={<Skeleton label="activity" lines={3} />}>
<ActivityFeed store={store} id={USER_ID} />
</Suspense>
</div>

<section aria-label="Notes" style={{ marginTop: token('space', '8'), color: token('color', 'muted') }}>
<h2 style={{ fontSize: token('fontSize', 'lg'), color: 'var(--color-text)' }}>Why this shape</h2>
<p>
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.
</p>
</section>
</main>
)
}
90 changes: 90 additions & 0 deletions src/streaming/data.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, Profile>> = {
u1: { id: 'u1', name: 'Ada Lovelace', role: 'Analyst' },
u2: { id: 'u2', name: 'Grace Hopper', role: 'Compiler engineer' }
}

const FEEDS: Readonly<Record<string, ActivityFeed>> = {
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<void>

const realWait: Wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

export interface DataStore {
profile(id: string): Promise<Profile>
activity(id: string): Promise<ActivityFeed>
recommendations(): Promise<readonly Recommendation[]>
}

// 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<string, Profile>(async (id) => {
await wait(LATENCY.profile)
const found = PROFILES[id]
if (found === undefined) throw new NotFoundError('profile', id)
return found
})

const feeds = createLoader<string, ActivityFeed>(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')
}
}
27 changes: 27 additions & 0 deletions src/streaming/loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type Fetcher<K, V> = (key: K) => Promise<V>

export interface Loader<K, V> {
load(key: K): Promise<V>
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<K, V>(fetcher: Fetcher<K, V>): Loader<K, V> {
const inflight = new Map<K, Promise<V>>()

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()]
}
}
59 changes: 59 additions & 0 deletions test/data.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
Loading
Loading