Skip to content

Commit 9e17d03

Browse files
ihabadhamclaude
andcommitted
Refactor RSC demo to canonical RoR Pro data flow
Drop self-fetch (Server Component → fetch → Rails) anti-pattern that docs/oss/migrating/rsc-data-fetching.md and rsc-troubleshooting.md explicitly warn against (circular request: Node renderer → Rails → Node renderer). Move comments loading into the Rails controller and pass them as props through stream_react_component, matching every maintained RSC demo and the dummy app. Changes: - PagesController#server_components now loads @server_components_comments from the Rails DB and renders via stream_view_containing_react_components. Includes ReactOnRailsPro::Stream (provides the helper + ActionController::Live). - View switches react_component → stream_react_component, passes comments as props. - ServerComponentsPage receives comments and forwards to CommentsFeed. - CommentsFeed becomes a pure-render Server Component: receives comments as a prop, drops fetch / AbortController / RAILS_INTERNAL_URL handling / try-catch error fallback / lodash. Keeps marked + sanitize-html for server-side markdown rendering. Suspense boundary stays; the demo delay (default-on, RSC_SUSPENSE_DEMO_DELAY=false to disable) gives Suspense visible work to wait on. - ServerInfo: drop unused async keyword. - No PropTypes added (React 19 removed propTypes runtime validation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fd9faf1 commit 9e17d03

5 files changed

Lines changed: 20 additions & 59 deletions

File tree

app/controllers/pages_controller.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
class PagesController < ApplicationController
44
include ReactOnRails::Controller
5+
include ReactOnRailsPro::Stream
56
before_action :set_comments
67

78
def index
@@ -38,7 +39,11 @@ def simple; end
3839

3940
def rescript; end
4041

41-
def server_components; end
42+
def server_components
43+
@server_components_comments = Comment.order(id: :desc).limit(10)
44+
.as_json(only: %i[id author text created_at])
45+
stream_view_containing_react_components(template: "/pages/server_components")
46+
end
4247

4348
private
4449

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
<%= react_component("ServerComponentsPage",
2-
prerender: false,
1+
<%= stream_react_component("ServerComponentsPage",
2+
props: { comments: @server_components_comments },
3+
prerender: true,
34
auto_load_bundle: true,
45
trace: Rails.env.development?,
56
id: "ServerComponentsPage-react-component-0") %>

client/app/bundles/server-components/components/CommentsFeed.jsx

Lines changed: 8 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,22 @@
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-
41
import React from 'react';
52
import { Marked } from 'marked';
63
import { gfmHeadingId } from 'marked-gfm-heading-id';
74
import sanitizeHtml from 'sanitize-html';
8-
import _ from 'lodash';
95
import TogglePanel from './TogglePanel';
106

117
const marked = new Marked();
128
marked.use(gfmHeadingId());
139

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') {
10+
// Default-on small delay so the surrounding <Suspense> fallback is visible
11+
// in the demo. Set RSC_SUSPENSE_DEMO_DELAY=false to disable (CI / tests).
12+
async function CommentsFeed({ comments = [] }) {
13+
if (process.env.RSC_SUSPENSE_DEMO_DELAY !== 'false') {
3014
await new Promise((resolve) => {
3115
setTimeout(resolve, 800);
3216
});
3317
}
3418

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) {
19+
if (comments.length === 0) {
6320
return (
6421
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 text-center">
6522
<p className="text-amber-700">
@@ -75,10 +32,8 @@ async function CommentsFeed() {
7532

7633
return (
7734
<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.
35+
{comments.map((comment) => {
36+
// marked + sanitize-html (~200KB combined) stay server-side.
8237
const rawHtml = marked.parse(comment.text || '');
8338
const safeHtml = sanitizeHtml(rawHtml, {
8439
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
@@ -119,7 +74,7 @@ async function CommentsFeed() {
11974
);
12075
})}
12176
<p className="text-xs text-slate-400 text-center pt-2">
122-
{recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '}
77+
{comments.length} comment{comments.length !== 1 ? 's' : ''} rendered on the server using{' '}
12378
<code>marked</code> + <code>sanitize-html</code> (never sent to browser)
12479
</p>
12580
</div>

client/app/bundles/server-components/components/ServerInfo.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import React from 'react';
55
import os from 'os';
66
import _ from 'lodash';
77

8-
async function ServerInfo() {
8+
function ServerInfo() {
99
const serverInfo = {
1010
platform: os.platform(),
1111
arch: os.arch(),

client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import ServerInfo from '../components/ServerInfo';
77
import CommentsFeed from '../components/CommentsFeed';
88
import TogglePanel from '../components/TogglePanel';
99

10-
const ServerComponentsPage = () => {
10+
const ServerComponentsPage = ({ comments = [] }) => {
1111
return (
1212
<div className="max-w-4xl mx-auto py-8 px-4">
1313
<header className="mb-10">
@@ -81,7 +81,7 @@ const ServerComponentsPage = () => {
8181
</div>
8282
}
8383
>
84-
<CommentsFeed />
84+
<CommentsFeed comments={comments} />
8585
</Suspense>
8686
</section>
8787

0 commit comments

Comments
 (0)