Skip to content

Commit 3cad164

Browse files
hi-ogawaOpenCode
andauthored
chore(rsc): add performance track example (#1304)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai>
1 parent a3a6cf7 commit 3cad164

13 files changed

Lines changed: 501 additions & 0 deletions

File tree

packages/plugin-rsc/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ npm create vite@latest -- --template rsc
2727

2828
- [`./examples/basic`](./examples/basic) - Advanced RSC features and testing
2929
- This is mainly used for e2e testing and includes various advanced RSC usages (e.g. `"use cache"` example).
30+
- [`./examples/performance-track`](./examples/performance-track) - Minimal React Server Components performance track probe.
3031
- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity.
3132
- [`./examples/react-router`](./examples/react-router) - React Router RSC integration
3233
- Demonstrates how to integrate [experimental React Router RSC API](https://remix.run/blog/rsc-preview). React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components), so it's recommended to follow React Router's official documentation for the latest integration.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { type CDPSession, expect, test } from '@playwright/test'
2+
import { useFixture } from './fixture'
3+
4+
test.describe('performance-track', () => {
5+
// Server Components performance tracks are flaky and require a Chromium trace
6+
// plus a React build that emits them (canary/experimental), so this is opt-in
7+
// via `TEST_PERFORMANCE_TRACK=1` for local runs and is not exercised in CI.
8+
test.skip(!process.env.TEST_PERFORMANCE_TRACK)
9+
10+
const f = useFixture({ root: 'examples/performance-track', mode: 'dev' })
11+
12+
test('emits server component performance tracks', async ({
13+
browserName,
14+
page,
15+
}) => {
16+
test.skip(browserName !== 'chromium')
17+
18+
const session = await page.context().newCDPSession(page)
19+
await startTracing(session)
20+
21+
await page.goto(f.url())
22+
// Wait for the innermost step so the whole waterfall has resolved.
23+
await expect(
24+
page.getByText('SlowServerComponent resolved after 500ms'),
25+
).toBeVisible()
26+
await page.getByRole('link', { name: 'About' }).click()
27+
await expect(page.getByRole('heading', { name: 'About' })).toBeVisible()
28+
await expect(
29+
page.getByText('SlowServerComponent resolved after 500ms'),
30+
).toBeVisible()
31+
await page.waitForTimeout(1000)
32+
33+
const spans = await stopTracingAndCollectServerComponentSpans(session)
34+
const slowSpans = spans.filter(
35+
(span) => span.name === 'SlowServerComponent',
36+
)
37+
// Two renders (Home + About), each with a nested pair of
38+
// SlowServerComponent spans forming the waterfall.
39+
expect(slowSpans).toHaveLength(4)
40+
expect(slowSpans.every((span) => span.duration >= 200)).toBe(true)
41+
})
42+
})
43+
44+
//
45+
// CDP tracing helpers
46+
//
47+
48+
// We drive the CDP `Tracing` domain (Tracing.start / tracingComplete stream,
49+
// read via IO): https://chromedevtools.github.io/devtools-protocol/tot/Tracing/
50+
// CDP leaves the individual event opaque (`dataCollected.value: object[]`), so
51+
// this minimal shape follows Chrome's Trace Event Format instead:
52+
// https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU
53+
interface TraceEvent {
54+
name: string
55+
ph: string
56+
ts: number
57+
id?: string
58+
id2?: { local?: string }
59+
args?: unknown
60+
}
61+
62+
// A named Server Components track entry with its measured duration (ms),
63+
// derived from paired begin/end trace events.
64+
interface ServerComponentSpan {
65+
name: string
66+
duration: number
67+
}
68+
69+
async function startTracing(session: CDPSession): Promise<void> {
70+
await session.send('Tracing.start', {
71+
categories: '-*,devtools.timeline,blink.user_timing',
72+
transferMode: 'ReturnAsStream',
73+
})
74+
}
75+
76+
async function stopTracingAndCollectServerComponentSpans(
77+
session: CDPSession,
78+
): Promise<ServerComponentSpan[]> {
79+
const events = await stopTracing(session)
80+
const ends = new Map(
81+
events
82+
.filter((event) => event.ph === 'e')
83+
.map((event) => [traceId(event), event]),
84+
)
85+
return events
86+
.filter(
87+
(event) =>
88+
event.ph === 'b' &&
89+
JSON.stringify(event.args).includes('Server Components'),
90+
)
91+
.map((event) => ({
92+
name: event.name.replaceAll('\u200b', ''),
93+
duration: ((ends.get(traceId(event))?.ts ?? event.ts) - event.ts) / 1000,
94+
}))
95+
}
96+
97+
async function stopTracing(session: CDPSession): Promise<TraceEvent[]> {
98+
const tracingComplete = new Promise<string>((resolve, reject) => {
99+
session.once('Tracing.tracingComplete', ({ stream }) => {
100+
if (stream) resolve(stream)
101+
else reject(new Error('Trace stream is missing'))
102+
})
103+
})
104+
await session.send('Tracing.end')
105+
const stream = await tracingComplete
106+
let trace = ''
107+
for (;;) {
108+
const chunk = await session.send('IO.read', { handle: stream })
109+
trace += chunk.data
110+
if (chunk.eof) break
111+
}
112+
await session.send('IO.close', { handle: stream })
113+
return (JSON.parse(trace) as { traceEvents: TraceEvent[] }).traceEvents
114+
}
115+
116+
function traceId(event: TraceEvent): string | undefined {
117+
return event.id2?.local ?? event.id
118+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# RSC performance track example
2+
3+
This example isolates React's Server Components performance tracks from SSR and application framework payloads.
4+
5+
## Demo
6+
7+
<img width="500" alt="Performance track example" src="https://github.com/user-attachments/assets/26e39e81-280e-4df5-9d44-e69b67980017" />
8+
9+
<img width="500" alt="Server Components performance track" src="https://github.com/user-attachments/assets/27327a73-c93d-4c75-ae3e-60b77fc29f90" />
10+
11+
1. From the repository root, run `pnpm override-react canary`, `pnpm install --no-frozen-lockfile`, and `pnpm build`.
12+
2. Run `pnpm dev` in this directory.
13+
3. Open Chrome DevTools and select the Performance panel.
14+
4. Select **Record and reload**.
15+
5. Let the Home page resolve, then follow **About** to capture the same workload through client navigation and an on-demand RSC request. After the About page resolves, wait another second for React's deferred performance flush.
16+
6. Stop recording and inspect the **Server Components** tracks.
17+
18+
Both paths produce a nested pair of `SlowServerComponent` spans. The 500ms inner component only starts after the 300ms outer component resolves, so the two appear as a staircase in the Server Components track instead of overlapping. The initial path uses the normal SSR and injected Flight stream, while client navigation fetches a second ReactNode payload.
19+
20+
## Payload shape
21+
22+
This fixture intentionally exports `RscPayload = ReactNode` and serializes a top-level React element. React's canary client can recover moved performance debug information from supported renderable root values. Frameworks that move this information into an arbitrary object or a derived promise may need to preserve React's internal `_debugInfo` themselves.
23+
24+
Waku [extends the recovery logic](https://github.com/dai-shi/waku/blob/3f88539dfd92aab9aa8db32a390d4eb8b143ee44/packages/waku/src/lib/vite-plugins/patch-rsdw.ts#L3) for its plain-object payload. Next.js [propagates `_debugInfo`](https://github.com/vercel/next.js/blob/153bf8ac5fa00888ef5fbb2b65cac12f0942a44f/packages/next/src/client/components/router-reducer/ppr-navigations.ts#L2250) onto framework-created promises. These are framework-specific integration details rather than requirements of `@vitejs/plugin-rsc` or the RSC protocol.
25+
26+
This fixture was verified with matching React packages at `19.3.0-canary-81e442ea-20260721`. React 19.2.8 emits timing data but loses moved debug information during the client performance flush, so Chrome shows track markers without component spans. See React's [debug-info recovery logic](https://github.com/facebook/react/blob/b740af2510de1e19fcb399abb862af26ff95ac80/packages/react-client/src/ReactFlightClient.js#L4518-L4535).
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-performance-track",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^19.2.8",
14+
"react-dom": "^19.2.8"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^19.2.17",
18+
"@types/react-dom": "^19.2.3",
19+
"@vitejs/plugin-react": "latest",
20+
"@vitejs/plugin-rsc": "latest",
21+
"rsc-html-stream": "^0.0.7",
22+
"vite": "^8.1.4"
23+
}
24+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
createFromFetch,
3+
createFromReadableStream,
4+
} from '@vitejs/plugin-rsc/browser'
5+
import React from 'react'
6+
import { hydrateRoot } from 'react-dom/client'
7+
import { rscStream } from 'rsc-html-stream/client'
8+
import type { RscPayload } from './entry.rsc.tsx'
9+
import { createRscRenderRequest } from './request.ts'
10+
11+
async function main() {
12+
const initialPayload = await createFromReadableStream<RscPayload>(rscStream)
13+
let setPayload: (payload: Awaited<RscPayload>) => void
14+
15+
function BrowserRoot() {
16+
const [payload, setPayload_] =
17+
React.useState<Awaited<RscPayload>>(initialPayload)
18+
19+
React.useEffect(() => {
20+
setPayload = (nextPayload) => {
21+
React.startTransition(() => setPayload_(nextPayload))
22+
}
23+
}, [])
24+
25+
React.useEffect(() => listenNavigation(fetchRscPayload), [])
26+
return payload
27+
}
28+
29+
async function fetchRscPayload() {
30+
const request = createRscRenderRequest(window.location.href)
31+
setPayload(await createFromFetch<RscPayload>(fetch(request)))
32+
}
33+
34+
hydrateRoot(document, <BrowserRoot />)
35+
36+
if (import.meta.hot) {
37+
import.meta.hot.on('rsc:update', fetchRscPayload)
38+
}
39+
}
40+
41+
function listenNavigation(onNavigation: () => void) {
42+
window.addEventListener('popstate', onNavigation)
43+
44+
function onClick(event: MouseEvent) {
45+
const link = (event.target as Element).closest('a')
46+
if (
47+
link instanceof HTMLAnchorElement &&
48+
link.origin === location.origin &&
49+
(!link.target || link.target === '_self') &&
50+
event.button === 0 &&
51+
!event.metaKey &&
52+
!event.ctrlKey &&
53+
!event.altKey &&
54+
!event.shiftKey &&
55+
!event.defaultPrevented
56+
) {
57+
event.preventDefault()
58+
history.pushState(null, '', link.href)
59+
onNavigation()
60+
}
61+
}
62+
document.addEventListener('click', onClick)
63+
64+
return () => {
65+
document.removeEventListener('click', onClick)
66+
window.removeEventListener('popstate', onNavigation)
67+
}
68+
}
69+
70+
main()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc'
2+
import type { ReactNode } from 'react'
3+
import { Root } from '../root.tsx'
4+
import { parseRenderRequest } from './request.ts'
5+
6+
// This fixture intentionally serializes the React tree itself instead of the
7+
// object-shaped payload used by larger examples. React can recover performance
8+
// debug info from a top-level React element, while arbitrary object wrappers
9+
// may need to preserve React's internal `_debugInfo` manually.
10+
export type RscPayload = ReactNode
11+
12+
export default { fetch: handler }
13+
14+
async function handler(request: Request): Promise<Response> {
15+
const renderRequest = parseRenderRequest(request)
16+
const payload: RscPayload = <Root url={renderRequest.url} />
17+
const rscStream = renderToReadableStream<RscPayload>(payload)
18+
19+
if (renderRequest.isRsc) {
20+
return new Response(rscStream, {
21+
headers: {
22+
'content-type': 'text/x-component;charset=utf-8',
23+
},
24+
})
25+
}
26+
27+
const ssrEntry = await import.meta.viteRsc.loadModule<
28+
typeof import('./entry.ssr.tsx')
29+
>('ssr', 'index')
30+
const htmlStream = await ssrEntry.renderHTML(rscStream)
31+
return new Response(htmlStream, {
32+
headers: {
33+
'content-type': 'text/html',
34+
},
35+
})
36+
}
37+
38+
if (import.meta.hot) {
39+
import.meta.hot.accept()
40+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
2+
import React from 'react'
3+
import { renderToReadableStream } from 'react-dom/server.edge'
4+
import { injectRSCPayload } from 'rsc-html-stream/server'
5+
import type { RscPayload } from './entry.rsc.tsx'
6+
7+
export async function renderHTML(
8+
rscStream: ReadableStream<Uint8Array>,
9+
): Promise<ReadableStream<Uint8Array>> {
10+
const [ssrStream, browserStream] = rscStream.tee()
11+
let payload: Promise<RscPayload> | undefined
12+
13+
function SsrRoot() {
14+
payload ??= createFromReadableStream<RscPayload>(ssrStream)
15+
return React.use(payload)
16+
}
17+
18+
const bootstrapScriptContent =
19+
await import.meta.viteRsc.loadBootstrapScriptContent('index')
20+
const htmlStream = await renderToReadableStream(<SsrRoot />, {
21+
bootstrapScriptContent,
22+
})
23+
return htmlStream.pipeThrough(injectRSCPayload(browserStream))
24+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const URL_POSTFIX = '_.rsc'
2+
3+
export function createRscRenderRequest(urlString: string): Request {
4+
const url = new URL(urlString)
5+
url.pathname += URL_POSTFIX
6+
return new Request(url)
7+
}
8+
9+
export function parseRenderRequest(request: Request): {
10+
isRsc: boolean
11+
url: URL
12+
} {
13+
const url = new URL(request.url)
14+
const isRsc = url.pathname.endsWith(URL_POSTFIX)
15+
if (isRsc) {
16+
url.pathname = url.pathname.slice(0, -URL_POSTFIX.length)
17+
}
18+
return { isRsc, url }
19+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
body {
2+
font-family: system-ui, sans-serif;
3+
line-height: 1.5;
4+
max-width: 42rem;
5+
margin: 2rem auto;
6+
padding: 0 1rem;
7+
}
8+
9+
nav {
10+
display: flex;
11+
gap: 1rem;
12+
margin-bottom: 2rem;
13+
}
14+
15+
.suspense-box {
16+
border: 2px solid #777;
17+
border-radius: 0.5rem;
18+
margin-top: 1rem;
19+
padding: 1rem;
20+
}
21+
22+
.suspense-box h3,
23+
.suspense-box p {
24+
margin: 0;
25+
}
26+
27+
.suspense-box p {
28+
margin-top: 0.25rem;
29+
}
30+
31+
.suspense-box.outer {
32+
background: #f3f6ff;
33+
border-color: #5b70a9;
34+
}
35+
36+
.suspense-box.inner {
37+
background: #fff8e6;
38+
border-color: #aa7b20;
39+
}

0 commit comments

Comments
 (0)