Skip to content

Commit 02b8b1c

Browse files
ihabadhamclaude
andcommitted
Add RSC demo components for /server-components page
Server Components (no 'use client'): - ServerComponentsPage.jsx: demo container showing RSC streaming - components/ServerInfo.jsx: displays server environment info - components/CommentsFeed.jsx: async data fetch with timeout, env-gated delay, img sanitization, data.comments unwrap Client Component ('use client'): - components/TogglePanel.jsx: interactive panel demonstrating 'use client' boundary within a server component tree Salvaged from Justin's PR 723 final state per the journey report KEEP table. CommentsFeed specifically from commit f008295 (has the fetch timeout + sanitization fixes from review). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8e8c518 commit 02b8b1c

4 files changed

Lines changed: 356 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Server Component - this entire component runs on the server.
2+
// It can use Node.js APIs and server-only dependencies directly.
3+
// None of these imports are shipped to the client bundle.
4+
5+
import React, { Suspense } from 'react';
6+
import ServerInfo from './components/ServerInfo';
7+
import CommentsFeed from './components/CommentsFeed';
8+
import TogglePanel from './components/TogglePanel';
9+
10+
const ServerComponentsPage = () => {
11+
return (
12+
<div className="max-w-4xl mx-auto py-8 px-4">
13+
<header className="mb-10">
14+
<h1 className="text-3xl font-bold text-slate-800 mb-2">
15+
React Server Components Demo
16+
</h1>
17+
<p className="text-slate-600 text-lg">
18+
This page is rendered using <strong>React Server Components</strong> with React on Rails Pro.
19+
Server components run on the server and stream their output to the client, keeping
20+
heavy dependencies out of the browser bundle entirely.
21+
</p>
22+
</header>
23+
24+
<div className="space-y-8">
25+
{/* Server Info - uses Node.js os module (impossible on client) */}
26+
<section>
27+
<h2 className="text-xl font-semibold text-slate-700 mb-4 flex items-center gap-2">
28+
Server Environment
29+
<span className="text-xs font-normal bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-full">
30+
Server Only
31+
</span>
32+
</h2>
33+
<ServerInfo />
34+
</section>
35+
36+
{/* Interactive toggle - demonstrates mixing server + client components */}
37+
<section>
38+
<h2 className="text-xl font-semibold text-slate-700 mb-4 flex items-center gap-2">
39+
Interactive Client Component
40+
<span className="text-xs font-normal bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">
41+
Client Hydrated
42+
</span>
43+
</h2>
44+
<TogglePanel title="How does this work?">
45+
<div className="prose prose-slate max-w-none text-sm">
46+
<p>
47+
This toggle is a <code>&apos;use client&apos;</code> component, meaning it ships JavaScript
48+
to the browser for interactivity. But the content inside is rendered on the server
49+
and passed as children — a key RSC pattern called the <strong>donut pattern</strong>.
50+
</p>
51+
<ul>
52+
<li>The <code>TogglePanel</code> wrapper runs on the client (handles click events)</li>
53+
<li>The children content is rendered on the server (no JS cost)</li>
54+
<li>Heavy libraries used by server components never reach the browser</li>
55+
</ul>
56+
</div>
57+
</TogglePanel>
58+
</section>
59+
60+
{/* Async data fetching with Suspense streaming */}
61+
<section>
62+
<h2 className="text-xl font-semibold text-slate-700 mb-4 flex items-center gap-2">
63+
Streamed Comments
64+
<span className="text-xs font-normal bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">
65+
Async + Suspense
66+
</span>
67+
</h2>
68+
<p className="text-slate-500 text-sm mb-4">
69+
Comments are fetched directly on the server using the Rails API.
70+
The page shell renders immediately while this section streams in progressively.
71+
</p>
72+
<Suspense
73+
fallback={
74+
<div className="animate-pulse space-y-3">
75+
{[1, 2, 3].map((i) => (
76+
<div key={i} className="bg-slate-100 rounded-lg p-4">
77+
<div className="h-4 bg-slate-200 rounded w-1/4 mb-2" />
78+
<div className="h-3 bg-slate-200 rounded w-3/4" />
79+
</div>
80+
))}
81+
</div>
82+
}
83+
>
84+
<CommentsFeed />
85+
</Suspense>
86+
</section>
87+
88+
{/* Architecture explanation */}
89+
<section className="bg-slate-50 border border-slate-200 rounded-xl p-6">
90+
<h2 className="text-lg font-semibold text-slate-700 mb-3">
91+
What makes this different?
92+
</h2>
93+
<div className="grid md:grid-cols-2 gap-4 text-sm text-slate-600">
94+
<div>
95+
<h3 className="font-medium text-slate-800 mb-1">Smaller Client Bundle</h3>
96+
<p>
97+
Libraries like <code>lodash</code>, <code>marked</code>, and Node.js <code>os</code> module
98+
are used on this page but never downloaded by the browser.
99+
</p>
100+
</div>
101+
<div>
102+
<h3 className="font-medium text-slate-800 mb-1">Direct Data Access</h3>
103+
<p>
104+
Server components fetch data by calling your Rails API internally — no
105+
client-side fetch waterfalls or loading spinners for initial data.
106+
</p>
107+
</div>
108+
<div>
109+
<h3 className="font-medium text-slate-800 mb-1">Progressive Streaming</h3>
110+
<p>
111+
The page shell renders instantly. Async components (like the comments feed)
112+
stream in as their data resolves, with Suspense boundaries showing fallbacks.
113+
</p>
114+
</div>
115+
<div>
116+
<h3 className="font-medium text-slate-800 mb-1">Selective Hydration</h3>
117+
<p>
118+
Only client components (like the toggle above) receive JavaScript.
119+
Everything else is pure HTML — zero hydration cost.
120+
</p>
121+
</div>
122+
</div>
123+
</section>
124+
</div>
125+
</div>
126+
);
127+
};
128+
129+
export default ServerComponentsPage;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Server Component - fetches comments directly from the Rails API on the server.
2+
// Uses marked for markdown rendering. Both fetch and marked stay server-side.
3+
4+
import React from 'react';
5+
import { Marked } from 'marked';
6+
import { gfmHeadingId } from 'marked-gfm-heading-id';
7+
import sanitizeHtml from 'sanitize-html';
8+
import _ from 'lodash';
9+
import TogglePanel from './TogglePanel';
10+
11+
const marked = new Marked();
12+
marked.use(gfmHeadingId());
13+
14+
function resolveRailsBaseUrl() {
15+
if (process.env.RAILS_INTERNAL_URL) {
16+
return process.env.RAILS_INTERNAL_URL;
17+
}
18+
19+
// Local defaults are okay in development/test, but production should be explicit.
20+
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
21+
return 'http://localhost:3000';
22+
}
23+
24+
throw new Error('RAILS_INTERNAL_URL must be set outside development/test');
25+
}
26+
27+
async function CommentsFeed() {
28+
// Simulate network latency only when explicitly enabled for demos.
29+
if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') {
30+
await new Promise((resolve) => {
31+
setTimeout(resolve, 800);
32+
});
33+
}
34+
35+
let recentComments = [];
36+
try {
37+
// Fetch comments directly from the Rails API — no client-side fetch needed
38+
const baseUrl = resolveRailsBaseUrl();
39+
const controller = new AbortController();
40+
const timeoutId = setTimeout(() => controller.abort(), 5000);
41+
const response = await fetch(`${baseUrl}/comments.json`, { signal: controller.signal });
42+
clearTimeout(timeoutId);
43+
if (!response.ok) {
44+
throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`);
45+
}
46+
const data = await response.json();
47+
const comments = data.comments;
48+
49+
// Use lodash to process (stays on server)
50+
const sortedComments = _.orderBy(comments, ['created_at'], ['desc']);
51+
recentComments = _.take(sortedComments, 10);
52+
} catch (error) {
53+
// eslint-disable-next-line no-console
54+
console.error('CommentsFeed failed to load comments', error);
55+
return (
56+
<div className="bg-rose-50 border border-rose-200 rounded-lg p-6 text-center">
57+
<p className="text-rose-700">Could not load comments right now. Please try again later.</p>
58+
</div>
59+
);
60+
}
61+
62+
if (recentComments.length === 0) {
63+
return (
64+
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 text-center">
65+
<p className="text-amber-700">
66+
No comments yet. Add some comments from the{' '}
67+
<a href="/" className="underline font-medium">
68+
home page
69+
</a>{' '}
70+
to see them rendered here by server components.
71+
</p>
72+
</div>
73+
);
74+
}
75+
76+
return (
77+
<div className="space-y-3">
78+
{recentComments.map((comment) => {
79+
// Render markdown on the server using marked + sanitize-html.
80+
// sanitize-html strips any dangerous HTML before rendering.
81+
// These libraries (combined ~200KB) never reach the client.
82+
const rawHtml = marked.parse(comment.text || '');
83+
const safeHtml = sanitizeHtml(rawHtml, {
84+
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
85+
allowedAttributes: {
86+
...sanitizeHtml.defaults.allowedAttributes,
87+
img: ['src', 'alt', 'title', 'width', 'height'],
88+
},
89+
allowedSchemes: ['https', 'http'],
90+
});
91+
92+
return (
93+
<div
94+
key={comment.id}
95+
className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
96+
>
97+
<div className="flex items-center justify-between mb-2">
98+
<span className="font-semibold text-slate-800">{comment.author}</span>
99+
<span className="text-xs text-slate-400">
100+
{new Date(comment.created_at).toLocaleDateString('en-US', {
101+
month: 'short',
102+
day: 'numeric',
103+
year: 'numeric',
104+
hour: '2-digit',
105+
minute: '2-digit',
106+
})}
107+
</span>
108+
</div>
109+
<TogglePanel title="Show rendered markdown">
110+
{/* Content is sanitized via sanitize-html before rendering */}
111+
{/* eslint-disable-next-line react/no-danger */}
112+
<div
113+
className="prose prose-sm prose-slate max-w-none"
114+
dangerouslySetInnerHTML={{ __html: safeHtml }}
115+
/>
116+
</TogglePanel>
117+
<p className="text-slate-600 text-sm mt-1">{comment.text}</p>
118+
</div>
119+
);
120+
})}
121+
<p className="text-xs text-slate-400 text-center pt-2">
122+
{recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '}
123+
<code>marked</code> + <code>sanitize-html</code> (never sent to browser)
124+
</p>
125+
</div>
126+
);
127+
}
128+
129+
export default CommentsFeed;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Server Component - uses Node.js os module, which only exists on the server.
2+
// This component and its dependencies are never sent to the browser.
3+
4+
import React from 'react';
5+
import os from 'os';
6+
import _ from 'lodash';
7+
8+
async function ServerInfo() {
9+
const serverInfo = {
10+
platform: os.platform(),
11+
arch: os.arch(),
12+
nodeVersion: process.version,
13+
uptime: Math.floor(os.uptime() / 3600),
14+
totalMemory: (os.totalmem() / (1024 * 1024 * 1024)).toFixed(1),
15+
freeMemory: (os.freemem() / (1024 * 1024 * 1024)).toFixed(1),
16+
cpus: os.cpus().length,
17+
hostname: os.hostname(),
18+
};
19+
20+
// Using lodash on the server — this 70KB+ library stays server-side
21+
const infoEntries = _.toPairs(serverInfo);
22+
const grouped = _.chunk(infoEntries, 4);
23+
24+
const labels = {
25+
platform: 'Platform',
26+
arch: 'Architecture',
27+
nodeVersion: 'Node.js',
28+
uptime: 'Uptime (hrs)',
29+
totalMemory: 'Total RAM (GB)',
30+
freeMemory: 'Free RAM (GB)',
31+
cpus: 'CPU Cores',
32+
hostname: 'Hostname',
33+
};
34+
35+
return (
36+
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 rounded-xl p-6">
37+
<p className="text-xs text-emerald-600 mb-4 font-medium">
38+
This data comes from the Node.js <code className="bg-emerald-100 px-1 rounded">os</code> module
39+
— it runs only on the server. The <code className="bg-emerald-100 px-1 rounded">lodash</code> library
40+
used to format it never reaches the browser.
41+
</p>
42+
<div className="grid md:grid-cols-2 gap-x-8 gap-y-1">
43+
{grouped.map((group) => (
44+
<div key={group.map(([k]) => k).join('-')} className="space-y-1">
45+
{group.map(([key, value]) => (
46+
<div key={key} className="flex justify-between py-1.5 border-b border-emerald-100 last:border-0">
47+
<span className="text-sm text-emerald-700 font-medium">{labels[key] || key}</span>
48+
<span className="text-sm text-emerald-900 font-mono">{value}</span>
49+
</div>
50+
))}
51+
</div>
52+
))}
53+
</div>
54+
</div>
55+
);
56+
}
57+
58+
export default ServerInfo;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import PropTypes from 'prop-types';
5+
6+
const TogglePanel = ({ title, children }) => {
7+
const [isOpen, setIsOpen] = useState(false);
8+
9+
return (
10+
<div className="border border-slate-200 rounded-lg overflow-hidden">
11+
<button
12+
type="button"
13+
onClick={() => setIsOpen((prev) => !prev)}
14+
className="w-full flex items-center justify-between px-4 py-2.5 bg-slate-50 hover:bg-slate-100 transition-colors text-left"
15+
>
16+
<span className="text-sm font-medium text-slate-700">{title}</span>
17+
<svg
18+
className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
19+
fill="none"
20+
viewBox="0 0 24 24"
21+
stroke="currentColor"
22+
>
23+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
24+
</svg>
25+
</button>
26+
{isOpen && (
27+
<div className="px-4 py-3 bg-white">
28+
{children}
29+
</div>
30+
)}
31+
</div>
32+
);
33+
};
34+
35+
TogglePanel.propTypes = {
36+
title: PropTypes.string.isRequired,
37+
children: PropTypes.node.isRequired,
38+
};
39+
40+
export default TogglePanel;

0 commit comments

Comments
 (0)