Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2424b1c
Enable RSC support in Pro initializer
ihabadham Apr 23, 2026
325f3e3
Add RSCWebpackPlugin to client webpack config
ihabadham Apr 23, 2026
2f11ffe
Create rscWebpackConfig.js for the RSC bundle
ihabadham Apr 23, 2026
1c4bef2
Wire RSC bundle into webpackConfig.js
ihabadham Apr 23, 2026
cf394ba
Add 'use client' to all registered component entry points
ihabadham Apr 23, 2026
8e8c518
Add /server-components route, controller action, and view
ihabadham Apr 23, 2026
02b8b1c
Add RSC demo components for /server-components page
ihabadham Apr 23, 2026
b770daa
Add RSC watcher to Procfile.dev and nav link to /server-components
ihabadham Apr 23, 2026
138befb
Include RSC bundle in default webpack build
ihabadham Apr 23, 2026
bc716e0
Move ServerComponentsPage to ror_components for auto-discovery
ihabadham Apr 23, 2026
649e0bd
Wire RSC loader for both SWC and Babel transpilers
ihabadham Apr 24, 2026
1ac1b27
Disable stubTimers in Pro Node renderer for RSC streaming
ihabadham Apr 24, 2026
fd9faf1
Enable replayServerAsyncOperationLogs on Pro Node renderer
ihabadham Apr 24, 2026
9e17d03
Refactor RSC demo to canonical RoR Pro data flow
ihabadham Apr 25, 2026
df039e2
Add RSCRoute + ErrorBoundary demo to RSC page
ihabadham Apr 25, 2026
f2d0d3d
Use refetchComponent explicitly in retry path
ihabadham Apr 25, 2026
0b626bc
Address PR review feedback
ihabadham Apr 26, 2026
09b113e
Add request + system specs for the RSC demo
ihabadham Apr 26, 2026
f23beba
Gate LiveActivity 300ms delay on RSC_SUSPENSE_DEMO_DELAY
ihabadham Apr 26, 2026
e2c76a5
Wrap RSCRoute in local Suspense to prevent whole-page collapse
ihabadham Apr 26, 2026
86eed7a
Add request spec variant exercising populated CommentsFeed
ihabadham Apr 26, 2026
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
2 changes: 2 additions & 0 deletions Procfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ rails: bundle exec thrust bin/rails server -p 3000
wp-client: RAILS_ENV=development NODE_ENV=development bin/shakapacker-dev-server
# Server webpack watcher for SSR bundle
wp-server: SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
# RSC webpack watcher for React Server Components bundle
wp-rsc: RSC_BUNDLE_ONLY=yes bin/shakapacker --watch
node-renderer: NODE_ENV=development node renderer/node-renderer.js
2 changes: 2 additions & 0 deletions app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def simple; end

def rescript; end

def server_components; end

private

def set_comments
Expand Down
5 changes: 5 additions & 0 deletions app/views/pages/server_components.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= react_component("ServerComponentsPage",
prerender: false,
auto_load_bundle: true,
trace: Rails.env.development?,
Comment thread
ihabadham marked this conversation as resolved.
Outdated
id: "ServerComponentsPage-react-component-0") %>
Comment thread
ihabadham marked this conversation as resolved.
Outdated
Comment thread
ihabadham marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import React from 'react';
import PropTypes from 'prop-types';
import BaseComponent from 'libs/components/BaseComponent';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ function NavigationBar(props) {
Rescript
</a>
</li>
<li>
<a
className={navItemClassName(pathname === paths.SERVER_COMPONENTS_PATH)}
href={paths.SERVER_COMPONENTS_PATH}
>
RSC Demo
</a>
</li>
<li>
<a
className={navItemClassName(false)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

// eslint-disable-next-line max-classes-per-file
import React from 'react';
import request from 'axios';
Expand Down
1 change: 1 addition & 0 deletions client/app/bundles/comments/constants/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export const RESCRIPT_PATH = '/rescript';
export const SIMPLE_REACT_PATH = '/simple';
export const STIMULUS_PATH = '/stimulus';
export const RAILS_PATH = '/comments';
export const SERVER_COMPONENTS_PATH = '/server-components';
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

// Wrapper for ReScript component to work with react_on_rails auto-registration
// react_on_rails looks for components in ror_components/ subdirectories

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { Provider } from 'react-redux';
import React from 'react';
import ReactOnRails from 'react-on-rails-pro';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

// Top level component for client side.
// Compare this to the ./ServerApp.jsx file which is used for server side rendering.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

// Compare to ./RouterApp.server.jsx
import { Provider } from 'react-redux';
import React from 'react';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.

// Compare to ./RouterApp.client.jsx
import { Provider } from 'react-redux';
import React from 'react';
Expand Down
129 changes: 129 additions & 0 deletions client/app/bundles/server-components/components/CommentsFeed.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Server Component - fetches comments directly from the Rails API on the server.
// Uses marked for markdown rendering. Both fetch and marked stay server-side.

import React from 'react';
import { Marked } from 'marked';
import { gfmHeadingId } from 'marked-gfm-heading-id';
import sanitizeHtml from 'sanitize-html';
import _ from 'lodash';
import TogglePanel from './TogglePanel';

const marked = new Marked();
marked.use(gfmHeadingId());

function resolveRailsBaseUrl() {
if (process.env.RAILS_INTERNAL_URL) {
return process.env.RAILS_INTERNAL_URL;
}

// Local defaults are okay in development/test, but production should be explicit.
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
return 'http://localhost:3000';
}

throw new Error('RAILS_INTERNAL_URL must be set outside development/test');
Comment thread
ihabadham marked this conversation as resolved.
Outdated
}

async function CommentsFeed() {
// Simulate network latency only when explicitly enabled for demos.
if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') {
await new Promise((resolve) => {
setTimeout(resolve, 800);
});
}

let recentComments = [];
try {
// Fetch comments directly from the Rails API — no client-side fetch needed
const baseUrl = resolveRailsBaseUrl();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${baseUrl}/comments.json`, { signal: controller.signal });
clearTimeout(timeoutId);
Comment thread
ihabadham marked this conversation as resolved.
Outdated
if (!response.ok) {
throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const comments = data.comments;

// Use lodash to process (stays on server)
const sortedComments = _.orderBy(comments, ['created_at'], ['desc']);
recentComments = _.take(sortedComments, 10);
} catch (error) {
// eslint-disable-next-line no-console
console.error('CommentsFeed failed to load comments', error);
return (
<div className="bg-rose-50 border border-rose-200 rounded-lg p-6 text-center">
<p className="text-rose-700">Could not load comments right now. Please try again later.</p>
</div>
);
}

if (recentComments.length === 0) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 text-center">
<p className="text-amber-700">
No comments yet. Add some comments from the{' '}
<a href="/" className="underline font-medium">
home page
</a>{' '}
to see them rendered here by server components.
</p>
</div>
);
}

return (
<div className="space-y-3">
{recentComments.map((comment) => {
Comment thread
ihabadham marked this conversation as resolved.
Outdated
// Render markdown on the server using marked + sanitize-html.
// sanitize-html strips any dangerous HTML before rendering.
// These libraries (combined ~200KB) never reach the client.
const rawHtml = marked.parse(comment.text || '');
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
const safeHtml = sanitizeHtml(rawHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'title', 'width', 'height'],
},
allowedSchemes: ['https', 'http'],
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
});
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.

return (
<div
key={comment.id}
className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-slate-800">{comment.author}</span>
<span className="text-xs text-slate-400">
{new Date(comment.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<TogglePanel title="Show rendered markdown">
{/* Content is sanitized via sanitize-html before rendering */}
{/* eslint-disable-next-line react/no-danger */}
<div
className="prose prose-sm prose-slate max-w-none"
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
</TogglePanel>
<p className="text-slate-600 text-sm mt-1">{comment.text}</p>
</div>
);
})}
<p className="text-xs text-slate-400 text-center pt-2">
{recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '}
<code>marked</code> + <code>sanitize-html</code> (never sent to browser)
</p>
</div>
);
}

export default CommentsFeed;
58 changes: 58 additions & 0 deletions client/app/bundles/server-components/components/ServerInfo.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Server Component - uses Node.js os module, which only exists on the server.
// This component and its dependencies are never sent to the browser.

import React from 'react';
import os from 'os';
import _ from 'lodash';

async function ServerInfo() {
Comment thread
ihabadham marked this conversation as resolved.
Outdated
const serverInfo = {
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version,
uptime: Math.floor(os.uptime() / 3600),
totalMemory: (os.totalmem() / (1024 * 1024 * 1024)).toFixed(1),
freeMemory: (os.freemem() / (1024 * 1024 * 1024)).toFixed(1),
cpus: os.cpus().length,
hostname: os.hostname(),
Comment thread
ihabadham marked this conversation as resolved.
Outdated
Comment thread
ihabadham marked this conversation as resolved.
Outdated
};

// Using lodash on the server — this 70KB+ library stays server-side
const infoEntries = _.toPairs(serverInfo);
const grouped = _.chunk(infoEntries, 4);

const labels = {
platform: 'Platform',
arch: 'Architecture',
nodeVersion: 'Node.js',
uptime: 'Uptime (hrs)',
totalMemory: 'Total RAM (GB)',
freeMemory: 'Free RAM (GB)',
cpus: 'CPU Cores',
hostname: 'Hostname',
};

return (
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 rounded-xl p-6">
<p className="text-xs text-emerald-600 mb-4 font-medium">
This data comes from the Node.js <code className="bg-emerald-100 px-1 rounded">os</code> module
— it runs only on the server. The <code className="bg-emerald-100 px-1 rounded">lodash</code> library
used to format it never reaches the browser.
</p>
<div className="grid md:grid-cols-2 gap-x-8 gap-y-1">
{grouped.map((group) => (
<div key={group.map(([k]) => k).join('-')} className="space-y-1">
{group.map(([key, value]) => (
<div key={key} className="flex justify-between py-1.5 border-b border-emerald-100 last:border-0">
<span className="text-sm text-emerald-700 font-medium">{labels[key] || key}</span>
<span className="text-sm text-emerald-900 font-mono">{value}</span>
</div>
))}
</div>
))}
</div>
</div>
);
}

export default ServerInfo;
40 changes: 40 additions & 0 deletions client/app/bundles/server-components/components/TogglePanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';

import React, { useState } from 'react';
import PropTypes from 'prop-types';

const TogglePanel = ({ title, children }) => {
const [isOpen, setIsOpen] = useState(false);

return (
<div className="border border-slate-200 rounded-lg overflow-hidden">
<button
Comment thread
ihabadham marked this conversation as resolved.
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-slate-50 hover:bg-slate-100 transition-colors text-left"
>
<span className="text-sm font-medium text-slate-700">{title}</span>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="px-4 py-3 bg-white">
{children}
</div>
)}
</div>
);
};

TogglePanel.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
};

export default TogglePanel;
Loading
Loading