Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
4 changes: 4 additions & 0 deletions .controlplane/templates/app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ spec:
value: '2'
- name: RENDERER_URL
value: http://localhost:3800
# Enable the artificial Suspense demo delay so the streaming fallback is
# visible on the review-app. Off by default in production deployments.
- name: RSC_SUSPENSE_DEMO_DELAY
value: 'true'
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
# RENDERER_PASSWORD and REACT_ON_RAILS_PRO_LICENSE must be created in the
# Control Plane Secret named by {{APP_SECRETS}} before deploy. cpflow
# resolves {{APP_SECRETS}} to `{APP_PREFIX}-secrets` — which means review
Expand Down
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
9 changes: 8 additions & 1 deletion app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

class PagesController < ApplicationController
include ReactOnRails::Controller
before_action :set_comments
include ReactOnRailsPro::Stream
before_action :set_comments, only: %i[index no_router]

def index
# NOTE: The below notes apply if you want to set the value of the props in the controller, as
Expand Down Expand Up @@ -38,6 +39,12 @@ def simple; end

def rescript; end

def server_components
@server_components_comments = Comment.order(id: :desc).limit(10)
.as_json(only: %i[id author text created_at updated_at])
stream_view_containing_react_components(template: "/pages/server_components")
Comment thread
ihabadham marked this conversation as resolved.
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 @@
<%= stream_react_component("ServerComponentsPage",
props: { comments: @server_components_comments },
prerender: true,
auto_load_bundle: true,
trace: Rails.env.development?) %>
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
84 changes: 84 additions & 0 deletions client/app/bundles/server-components/components/CommentsFeed.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';
import { Marked } from 'marked';
import { gfmHeadingId } from 'marked-gfm-heading-id';
import sanitizeHtml from 'sanitize-html';
import TogglePanel from './TogglePanel';

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

// Opt-in delay so the surrounding <Suspense> fallback is visible in the demo.
// Set RSC_SUSPENSE_DEMO_DELAY=true to enable; defaults off in production.
async function CommentsFeed({ comments = [] }) {
if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') {
await new Promise((resolve) => {
setTimeout(resolve, 800);
});
}

if (comments.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">
{comments.map((comment) => {
// marked + sanitize-html (~200KB combined) stay server-side.
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">
{comments.length} comment{comments.length !== 1 ? 's' : ''} rendered on the server using{' '}
<code>marked</code> + <code>sanitize-html</code> (never sent to browser)
</p>
</div>
);
}

export default CommentsFeed;
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import React, { useState, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import RSCRoute from 'react-on-rails-pro/RSCRoute';
import { useRSC } from 'react-on-rails-pro/RSCProvider';

// Same shape and dimensions as the rendered LiveActivity card. Local Suspense
// fallback prevents the RSCRoute suspension from bubbling to an outer
// boundary, which would collapse the whole page during in-flight fetches.
const ActivityCardSkeleton = () => (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-5">
<div className="grid grid-cols-3 gap-4 text-sm">
{['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => (
<div key={label}>
<div className="text-xs text-indigo-600 font-medium uppercase tracking-wide mb-1">
{label}
</div>
<div className="font-mono text-indigo-300 animate-pulse">—</div>
</div>
))}
</div>
</div>
);

const LiveActivityRefresher = () => {
const [refreshKey, setRefreshKey] = useState(0);
const [simulateError, setSimulateError] = useState(false);
const { refetchComponent } = useRSC();

const handleRefresh = () => {
setSimulateError(false);
setRefreshKey((k) => k + 1);
};

const handleSimulateError = () => {
setSimulateError(true);
setRefreshKey((k) => k + 1);
};

// refetchComponent primes the cache with corrected props before resetting
// the boundary, so the post-reset render hits cache instead of re-fetching.
const buildRetry = (resetErrorBoundary) => () => {
const newKey = refreshKey + 1;
Comment thread
ihabadham marked this conversation as resolved.
setSimulateError(false);
setRefreshKey(newKey);
refetchComponent('LiveActivity', { simulateError: false, refreshKey: newKey })
// eslint-disable-next-line no-console
Comment thread
ihabadham marked this conversation as resolved.
.catch((err) => console.error('Retry refetch failed:', err))
.finally(() => resetErrorBoundary());
Comment thread
ihabadham marked this conversation as resolved.
Comment thread
ihabadham marked this conversation as resolved.
};

return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleRefresh}
className="px-3 py-1.5 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Refresh
</button>
<button
type="button"
onClick={handleSimulateError}
className="px-3 py-1.5 text-sm bg-amber-100 text-amber-800 border border-amber-300 rounded hover:bg-amber-200"
>
Simulate Error
</button>
<span className="text-xs text-slate-500 ml-2">Refresh count: {refreshKey}</span>
</div>
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="bg-rose-50 border border-rose-200 rounded-lg p-4">
<p className="text-rose-700 font-semibold mb-1">Server component fetch failed</p>
<p className="text-rose-600 text-sm font-mono mb-3">{error.message}</p>
<button
type="button"
onClick={buildRetry(resetErrorBoundary)}
className="px-3 py-1.5 text-sm bg-rose-600 text-white rounded hover:bg-rose-700"
>
Retry
</button>
</div>
)}
resetKeys={[refreshKey]}
>
<Suspense fallback={<ActivityCardSkeleton />}>
<RSCRoute componentName="LiveActivity" componentProps={{ simulateError, refreshKey }} />
Comment thread
ihabadham marked this conversation as resolved.
</Suspense>
</ErrorBoundary>
</div>
);
};

export default LiveActivityRefresher;
56 changes: 56 additions & 0 deletions client/app/bundles/server-components/components/ServerInfo.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 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';

function ServerInfo() {
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,
};

// 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',
};

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