From 2424b1c780dc965fd3f147b79be4cd7405dcc2cf Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 20:56:18 +0200 Subject: [PATCH 01/21] Enable RSC support in Pro initializer Add the three RSC fields per the marketplace demo initializer (react-on-rails-demo-marketplace-rsc/config/initializers/ react_on_rails_pro.rb): - enable_rsc_support = true - rsc_bundle_js_file = "rsc-bundle.js" - rsc_payload_generation_url_path = "rsc_payload/" Co-Authored-By: Claude Opus 4.7 (1M context) --- config/initializers/react_on_rails_pro.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb index 4e4f0389..fc5f3900 100644 --- a/config/initializers/react_on_rails_pro.rb +++ b/config/initializers/react_on_rails_pro.rb @@ -16,4 +16,8 @@ # so a blank env var (.env.example ships with `RENDERER_PASSWORD=`) # falls back to the dev default, matching the JS side's `||`. config.renderer_password = ENV["RENDERER_PASSWORD"].presence || "local-dev-renderer-password" + + config.enable_rsc_support = true + config.rsc_bundle_js_file = "rsc-bundle.js" + config.rsc_payload_generation_url_path = "rsc_payload/" end From 325f3e3fd7d968c535b1c953046e529f8b484ed0 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 20:58:33 +0200 Subject: [PATCH 02/21] Add RSCWebpackPlugin to client webpack config RSCWebpackPlugin({ isServer: false }) on the client bundle scans for 'use client' files and adds them as entry points so they appear in the client manifest (react-client-manifest.json). Without this, client components referenced in RSC payloads wouldn't have matching chunks in the client bundle. clientReferences scoped to config.source_path, consistent with the server bundle's scoping in serverWebpackConfig.js. Reference: Pro dummy clientWebpackConfig.js:16-24. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/webpack/clientWebpackConfig.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/config/webpack/clientWebpackConfig.js b/config/webpack/clientWebpackConfig.js index 6352208f..ea5957c0 100644 --- a/config/webpack/clientWebpackConfig.js +++ b/config/webpack/clientWebpackConfig.js @@ -1,6 +1,9 @@ // The source code including full typescript support is available at: // https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/clientWebpackConfig.js +const path = require('path'); +const { config } = require('shakapacker'); +const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin'); const commonWebpackConfig = require('./commonWebpackConfig'); const { getBundler } = require('./bundlerUtils'); @@ -22,6 +25,16 @@ const configureClient = () => { // client config is going to try to load chunks. delete clientConfig.entry['server-bundle']; + const clientReferencesDir = path.resolve(config.source_path || 'client/app'); + clientConfig.plugins.push( + new RSCWebpackPlugin({ + isServer: false, + clientReferences: [ + { directory: clientReferencesDir, recursive: true, include: /\.(js|ts|jsx|tsx)$/ }, + ], + }), + ); + return clientConfig; }; From 2f11ffec73f219537577a753b900193dd36b1276 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 20:59:50 +0200 Subject: [PATCH 03/21] Create rscWebpackConfig.js for the RSC bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derives from serverWebpackConfig(true) — inherits target:'node', libraryTarget:'commonjs2', CSS filtering, and all server transforms. Adds three RSC-specific pieces: 1. RSC WebpackLoader pushed into the babel rule's use array (runs before babel per right-to-left order) to detect 'use client' directives in raw source and replace client exports with registerClientReference proxies. 2. react-server resolve condition so React's RSC-specific entry points are used. 3. react-dom/server aliased to false (RSC bundles generate Flight payloads, not HTML; importing react-dom/server causes a runtime error). Loader placement follows Pro dummy pattern (push into rule.use) per docs/oss/migrating/rsc-preparing-app.md:167-195. NOT marketplace's enforce:'post' which runs after transpilation and can miss directive AST nodes. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/webpack/rscWebpackConfig.js | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 config/webpack/rscWebpackConfig.js diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js new file mode 100644 index 00000000..eeb88cdd --- /dev/null +++ b/config/webpack/rscWebpackConfig.js @@ -0,0 +1,40 @@ +const { default: serverWebpackConfig, extractLoader } = require('./serverWebpackConfig'); + +const configureRsc = () => { + const rscConfig = serverWebpackConfig(true); + + const rscEntry = { + 'rsc-bundle': rscConfig.entry['server-bundle'], + }; + rscConfig.entry = rscEntry; + + // Runs before babel-loader (webpack loaders execute right-to-left) to + // detect 'use client' directives in raw source before transpilation. + const { rules } = rscConfig.module; + rules.forEach((rule) => { + if (Array.isArray(rule.use)) { + const babelLoader = extractLoader(rule, 'babel-loader'); + if (babelLoader) { + rule.use.push({ + loader: 'react-on-rails-rsc/WebpackLoader', + }); + } + } + }); + + rscConfig.resolve = { + ...rscConfig.resolve, + conditionNames: ['react-server', '...'], + alias: { + ...rscConfig.resolve?.alias, + // RSC payload generation doesn't need react-dom/server; importing + // it in the react-server environment causes a runtime error. + 'react-dom/server': false, + }, + }; + + rscConfig.output.filename = 'rsc-bundle.js'; + return rscConfig; +}; + +module.exports = configureRsc; From 1c4bef2cbaefa43e4724d96fe33fd042ff6fe726 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 21:01:29 +0200 Subject: [PATCH 04/21] Wire RSC bundle into webpackConfig.js Add RSC_BUNDLE_ONLY env gate alongside the existing SERVER_BUNDLE_ONLY and CLIENT_BUNDLE_ONLY gates. Procfile.dev will use RSC_BUNDLE_ONLY=yes bin/shakapacker --watch to build the RSC bundle separately during development. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/webpack/webpackConfig.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js index 44327e8b..85e3fea4 100644 --- a/config/webpack/webpackConfig.js +++ b/config/webpack/webpackConfig.js @@ -3,6 +3,7 @@ const clientWebpackConfig = require('./clientWebpackConfig'); const { default: serverWebpackConfig } = require('./serverWebpackConfig'); +const rscWebpackConfig = require('./rscWebpackConfig'); const webpackConfig = (envSpecific) => { const clientConfig = clientWebpackConfig(); @@ -22,6 +23,10 @@ const webpackConfig = (envSpecific) => { // eslint-disable-next-line no-console console.log('[React on Rails] Creating only the server bundle.'); result = serverConfig; + } else if (process.env.RSC_BUNDLE_ONLY) { + // eslint-disable-next-line no-console + console.log('[React on Rails] Creating only the RSC bundle.'); + result = rscWebpackConfig(); } else { // default is the standard client and server build // eslint-disable-next-line no-console From cf394ba91befa30f6638af21c81f1424cfca858a Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 21:03:52 +0200 Subject: [PATCH 05/21] Add 'use client' to all registered component entry points RSC auto-classification: files without 'use client' are registered as Server Components via registerServerComponent(). All existing components are Client Components (hooks, Redux, Router, event handlers), so they need the directive to preserve current behavior. Entry points (7 ror_components/ files): - App.jsx, NavigationBarApp.jsx, RouterApp.client.jsx, RouterApp.server.jsx (SSR wrapper, NOT a Server Component), SimpleCommentScreen.jsx, Footer.jsx, RescriptShow.jsx Pack entry files (2): - stores-registration.js, stimulus-bundle.js Per docs/oss/migrating/rsc-preparing-app.md Step 5 and docs/pro/react-server-components/create-without-ssr.md:52. Matches Justin's PR 723 final state exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../comments/components/Footer/ror_components/Footer.jsx | 2 ++ .../SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx | 2 ++ .../rescript/ReScriptShow/ror_components/RescriptShow.jsx | 2 ++ client/app/bundles/comments/startup/App/ror_components/App.jsx | 2 ++ .../NavigationBarApp/ror_components/NavigationBarApp.jsx | 2 ++ .../startup/RouterApp/ror_components/RouterApp.client.jsx | 2 ++ .../startup/RouterApp/ror_components/RouterApp.server.jsx | 2 ++ client/app/packs/stimulus-bundle.js | 2 ++ client/app/packs/stores-registration.js | 2 ++ 9 files changed, 18 insertions(+) diff --git a/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx b/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx index 5e7f4210..d153dfb2 100644 --- a/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx +++ b/client/app/bundles/comments/components/Footer/ror_components/Footer.jsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import PropTypes from 'prop-types'; import BaseComponent from 'libs/components/BaseComponent'; diff --git a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx index 097cd750..157943da 100644 --- a/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx +++ b/client/app/bundles/comments/components/SimpleCommentScreen/ror_components/SimpleCommentScreen.jsx @@ -1,3 +1,5 @@ +'use client'; + // eslint-disable-next-line max-classes-per-file import React from 'react'; import request from 'axios'; diff --git a/client/app/bundles/comments/rescript/ReScriptShow/ror_components/RescriptShow.jsx b/client/app/bundles/comments/rescript/ReScriptShow/ror_components/RescriptShow.jsx index 395a6fff..96ae0065 100644 --- a/client/app/bundles/comments/rescript/ReScriptShow/ror_components/RescriptShow.jsx +++ b/client/app/bundles/comments/rescript/ReScriptShow/ror_components/RescriptShow.jsx @@ -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 diff --git a/client/app/bundles/comments/startup/App/ror_components/App.jsx b/client/app/bundles/comments/startup/App/ror_components/App.jsx index 63eb6532..82359cf1 100644 --- a/client/app/bundles/comments/startup/App/ror_components/App.jsx +++ b/client/app/bundles/comments/startup/App/ror_components/App.jsx @@ -1,3 +1,5 @@ +'use client'; + import { Provider } from 'react-redux'; import React from 'react'; import ReactOnRails from 'react-on-rails-pro'; diff --git a/client/app/bundles/comments/startup/NavigationBarApp/ror_components/NavigationBarApp.jsx b/client/app/bundles/comments/startup/NavigationBarApp/ror_components/NavigationBarApp.jsx index 911861be..bb6c053d 100644 --- a/client/app/bundles/comments/startup/NavigationBarApp/ror_components/NavigationBarApp.jsx +++ b/client/app/bundles/comments/startup/NavigationBarApp/ror_components/NavigationBarApp.jsx @@ -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. diff --git a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.client.jsx b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.client.jsx index caa2e06d..43183296 100644 --- a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.client.jsx +++ b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.client.jsx @@ -1,3 +1,5 @@ +'use client'; + // Compare to ./RouterApp.server.jsx import { Provider } from 'react-redux'; import React from 'react'; diff --git a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx index dd3578ba..7dd9d0a8 100644 --- a/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx +++ b/client/app/bundles/comments/startup/RouterApp/ror_components/RouterApp.server.jsx @@ -1,3 +1,5 @@ +'use client'; + // Compare to ./RouterApp.client.jsx import { Provider } from 'react-redux'; import React from 'react'; diff --git a/client/app/packs/stimulus-bundle.js b/client/app/packs/stimulus-bundle.js index 2664fee2..07dedc01 100644 --- a/client/app/packs/stimulus-bundle.js +++ b/client/app/packs/stimulus-bundle.js @@ -1,3 +1,5 @@ +'use client'; + import ReactOnRails from 'react-on-rails-pro'; import 'jquery-ujs'; import { Turbo } from '@hotwired/turbo-rails'; diff --git a/client/app/packs/stores-registration.js b/client/app/packs/stores-registration.js index d03732dc..cecf0a95 100644 --- a/client/app/packs/stores-registration.js +++ b/client/app/packs/stores-registration.js @@ -1,3 +1,5 @@ +'use client'; + import ReactOnRails from 'react-on-rails-pro'; import routerCommentsStore from '../bundles/comments/store/routerCommentsStore'; import commentsStore from '../bundles/comments/store/commentsStore'; From 8e8c5189a92a8bad98f5fc0c5ce33c07de060fe2 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 21:07:40 +0200 Subject: [PATCH 06/21] Add /server-components route, controller action, and view - rsc_payload_route in routes.rb enables the Flight protocol endpoint for client-side RSC payload fetching. - get "server-components" route maps to pages#server_components. - View uses prerender: false (RSC components are streamed via the payload route, not traditional SSR prerender) and auto_load_bundle: false (ServerComponentsPage is not in ror_components/, so auto-discovery doesn't find its pack). - trace: Rails.env.development? gates server-timing headers to dev. Reference: Justin's PR 723 commits 4d09e130 + 0d8d75ac. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/pages_controller.rb | 2 ++ app/views/pages/server_components.html.erb | 5 +++++ config/routes.rb | 3 +++ 3 files changed, 10 insertions(+) create mode 100644 app/views/pages/server_components.html.erb diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 507cc6cf..f435f04e 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -38,6 +38,8 @@ def simple; end def rescript; end + def server_components; end + private def set_comments diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb new file mode 100644 index 00000000..fccc13e8 --- /dev/null +++ b/app/views/pages/server_components.html.erb @@ -0,0 +1,5 @@ +<%= react_component("ServerComponentsPage", + prerender: false, + auto_load_bundle: false, + trace: Rails.env.development?, + id: "ServerComponentsPage-react-component-0") %> diff --git a/config/routes.rb b/config/routes.rb index 1d8c7b7a..353819a3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,12 +1,15 @@ # frozen_string_literal: true Rails.application.routes.draw do + rsc_payload_route + # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html # Serve websocket cable requests in-process # mount ActionCable.server => '/cable' root "pages#index" + get "server-components", to: "pages#server_components" get "simple", to: "pages#simple" get "rescript", to: "pages#rescript" From 02b8b1caf0d249cf51b2a26db6e3df5b0259a3b0 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 21:09:18 +0200 Subject: [PATCH 07/21] 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 f008295e (has the fetch timeout + sanitization fixes from review). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ServerComponentsPage.jsx | 129 ++++++++++++++++++ .../components/CommentsFeed.jsx | 129 ++++++++++++++++++ .../components/ServerInfo.jsx | 58 ++++++++ .../components/TogglePanel.jsx | 40 ++++++ 4 files changed, 356 insertions(+) create mode 100644 client/app/bundles/server-components/ServerComponentsPage.jsx create mode 100644 client/app/bundles/server-components/components/CommentsFeed.jsx create mode 100644 client/app/bundles/server-components/components/ServerInfo.jsx create mode 100644 client/app/bundles/server-components/components/TogglePanel.jsx diff --git a/client/app/bundles/server-components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ServerComponentsPage.jsx new file mode 100644 index 00000000..9fd567ea --- /dev/null +++ b/client/app/bundles/server-components/ServerComponentsPage.jsx @@ -0,0 +1,129 @@ +// Server Component - this entire component runs on the server. +// It can use Node.js APIs and server-only dependencies directly. +// None of these imports are shipped to the client bundle. + +import React, { Suspense } from 'react'; +import ServerInfo from './components/ServerInfo'; +import CommentsFeed from './components/CommentsFeed'; +import TogglePanel from './components/TogglePanel'; + +const ServerComponentsPage = () => { + return ( +
+
+

+ React Server Components Demo +

+

+ This page is rendered using React Server Components with React on Rails Pro. + Server components run on the server and stream their output to the client, keeping + heavy dependencies out of the browser bundle entirely. +

+
+ +
+ {/* Server Info - uses Node.js os module (impossible on client) */} +
+

+ Server Environment + + Server Only + +

+ +
+ + {/* Interactive toggle - demonstrates mixing server + client components */} +
+

+ Interactive Client Component + + Client Hydrated + +

+ +
+

+ This toggle is a 'use client' component, meaning it ships JavaScript + to the browser for interactivity. But the content inside is rendered on the server + and passed as children — a key RSC pattern called the donut pattern. +

+
    +
  • The TogglePanel wrapper runs on the client (handles click events)
  • +
  • The children content is rendered on the server (no JS cost)
  • +
  • Heavy libraries used by server components never reach the browser
  • +
+
+
+
+ + {/* Async data fetching with Suspense streaming */} +
+

+ Streamed Comments + + Async + Suspense + +

+

+ Comments are fetched directly on the server using the Rails API. + The page shell renders immediately while this section streams in progressively. +

+ + {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ } + > + + +
+ + {/* Architecture explanation */} +
+

+ What makes this different? +

+
+
+

Smaller Client Bundle

+

+ Libraries like lodash, marked, and Node.js os module + are used on this page but never downloaded by the browser. +

+
+
+

Direct Data Access

+

+ Server components fetch data by calling your Rails API internally — no + client-side fetch waterfalls or loading spinners for initial data. +

+
+
+

Progressive Streaming

+

+ The page shell renders instantly. Async components (like the comments feed) + stream in as their data resolves, with Suspense boundaries showing fallbacks. +

+
+
+

Selective Hydration

+

+ Only client components (like the toggle above) receive JavaScript. + Everything else is pure HTML — zero hydration cost. +

+
+
+
+
+
+ ); +}; + +export default ServerComponentsPage; diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx new file mode 100644 index 00000000..ce2a80ee --- /dev/null +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -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'); +} + +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); + 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 ( +
+

Could not load comments right now. Please try again later.

+
+ ); + } + + if (recentComments.length === 0) { + return ( +
+

+ No comments yet. Add some comments from the{' '} + + home page + {' '} + to see them rendered here by server components. +

+
+ ); + } + + return ( +
+ {recentComments.map((comment) => { + // 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 || ''); + const safeHtml = sanitizeHtml(rawHtml, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ['src', 'alt', 'title', 'width', 'height'], + }, + allowedSchemes: ['https', 'http'], + }); + + return ( +
+
+ {comment.author} + + {new Date(comment.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + +
+ + {/* Content is sanitized via sanitize-html before rendering */} + {/* eslint-disable-next-line react/no-danger */} +
+ +

{comment.text}

+
+ ); + })} +

+ {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '} + marked + sanitize-html (never sent to browser) +

+
+ ); +} + +export default CommentsFeed; diff --git a/client/app/bundles/server-components/components/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx new file mode 100644 index 00000000..4475eb82 --- /dev/null +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -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() { + 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(), + }; + + // 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 ( +
+

+ This data comes from the Node.js os module + — it runs only on the server. The lodash library + used to format it never reaches the browser. +

+
+ {grouped.map((group) => ( +
k).join('-')} className="space-y-1"> + {group.map(([key, value]) => ( +
+ {labels[key] || key} + {value} +
+ ))} +
+ ))} +
+
+ ); +} + +export default ServerInfo; diff --git a/client/app/bundles/server-components/components/TogglePanel.jsx b/client/app/bundles/server-components/components/TogglePanel.jsx new file mode 100644 index 00000000..1336b56b --- /dev/null +++ b/client/app/bundles/server-components/components/TogglePanel.jsx @@ -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 ( +
+ + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; + +TogglePanel.propTypes = { + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +export default TogglePanel; From b770daa762a45ede925fd7badce9ddb1d2d39d16 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 21:13:08 +0200 Subject: [PATCH 08/21] Add RSC watcher to Procfile.dev and nav link to /server-components - Procfile.dev: wp-rsc process runs RSC_BUNDLE_ONLY=yes bin/shakapacker --watch alongside existing client, server, and renderer processes. - paths.js: SERVER_COMPONENTS_PATH constant. - NavigationBar.jsx: "RSC Demo" link in the nav bar. Co-Authored-By: Claude Opus 4.7 (1M context) --- Procfile.dev | 2 ++ .../comments/components/NavigationBar/NavigationBar.jsx | 8 ++++++++ client/app/bundles/comments/constants/paths.js | 1 + 3 files changed, 11 insertions(+) diff --git a/Procfile.dev b/Procfile.dev index 20cd0f7b..71c6a4b1 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -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 diff --git a/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx b/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx index db2b4e53..30b99f37 100644 --- a/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx +++ b/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx @@ -102,6 +102,14 @@ function NavigationBar(props) { Rescript +
  • + + RSC Demo + +
  • Date: Thu, 23 Apr 2026 23:11:45 +0200 Subject: [PATCH 09/21] Include RSC bundle in default webpack build The default branch (no env vars) runs during bin/shakapacker for production/CI builds. Without the RSC config in the array, the RSC bundle only gets built when RSC_BUNDLE_ONLY is set (dev watchers). Production deploys + CI would miss it. The *_BUNDLE_ONLY gates remain for dev Procfile processes (each watcher builds one bundle in isolation). Co-Authored-By: Claude Opus 4.7 (1M context) --- config/webpack/webpackConfig.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js index 85e3fea4..b1783513 100644 --- a/config/webpack/webpackConfig.js +++ b/config/webpack/webpackConfig.js @@ -28,10 +28,9 @@ const webpackConfig = (envSpecific) => { console.log('[React on Rails] Creating only the RSC bundle.'); result = rscWebpackConfig(); } else { - // default is the standard client and server build // eslint-disable-next-line no-console - console.log('[React on Rails] Creating both client and server bundles.'); - result = [clientConfig, serverConfig]; + console.log('[React on Rails] Creating client, server, and RSC bundles.'); + result = [clientConfig, serverConfig, rscWebpackConfig()]; } // To debug, uncomment next line and inspect "result" From bc716e0cf090526ba020a56345469ee2d373bed1 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Thu, 23 Apr 2026 23:20:42 +0200 Subject: [PATCH 10/21] Move ServerComponentsPage to ror_components for auto-discovery Justin's PR used manual registerServerComponent() in stimulus-bundle.js because his custom rspackRscPlugin didn't integrate with the auto-bundling flow. With the upstream RSCWebpackPlugin, auto-bundling works: the generate_packs task scans ror_components/ directories, classifies files without 'use client' as Server Components, and generates the registration file in generated/ServerComponentsPage.js automatically. Moved from: bundles/server-components/ServerComponentsPage.jsx Moved to: bundles/server-components/ror_components/ServerComponentsPage.jsx Updated relative imports (./components/ -> ../components/) and flipped the view from auto_load_bundle: false to true. No manual registration, no stimulus-bundle.js modification. Matches the Pro dummy pattern where server component sources sit in the auto-discovered directory. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/views/pages/server_components.html.erb | 2 +- .../{ => ror_components}/ServerComponentsPage.jsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename client/app/bundles/server-components/{ => ror_components}/ServerComponentsPage.jsx (97%) diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb index fccc13e8..582e5c11 100644 --- a/app/views/pages/server_components.html.erb +++ b/app/views/pages/server_components.html.erb @@ -1,5 +1,5 @@ <%= react_component("ServerComponentsPage", prerender: false, - auto_load_bundle: false, + auto_load_bundle: true, trace: Rails.env.development?, id: "ServerComponentsPage-react-component-0") %> diff --git a/client/app/bundles/server-components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx similarity index 97% rename from client/app/bundles/server-components/ServerComponentsPage.jsx rename to client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx index 9fd567ea..22c53fc2 100644 --- a/client/app/bundles/server-components/ServerComponentsPage.jsx +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -3,9 +3,9 @@ // None of these imports are shipped to the client bundle. import React, { Suspense } from 'react'; -import ServerInfo from './components/ServerInfo'; -import CommentsFeed from './components/CommentsFeed'; -import TogglePanel from './components/TogglePanel'; +import ServerInfo from '../components/ServerInfo'; +import CommentsFeed from '../components/CommentsFeed'; +import TogglePanel from '../components/TogglePanel'; const ServerComponentsPage = () => { return ( From 649e0bdb480dce1ee4cefd123ada26dea8aa1c64 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Fri, 24 Apr 2026 20:48:37 +0300 Subject: [PATCH 11/21] Wire RSC loader for both SWC and Babel transpilers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation only handled Array.isArray(rule.use) and only looked for babel-loader. The tutorial uses swc as its transpiler (shakapacker.yml: javascript_transpiler: swc), which makes Shakapacker generate rule.use as a FUNCTION, not an array. The RSC loader was therefore never attached to the transpilation rule — 'use client' files were left untransformed in the RSC bundle, producing 134 webpack warnings (export 'useState' not found in 'react' etc.) and setting up a runtime error when the RSC renderer would try to call client components directly instead of emitting client references. Follow the pattern from docs/oss/migrating/rsc-preparing-app.md:167 verbatim, which handles both forms and both loader names. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/webpack/rscWebpackConfig.js | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js index eeb88cdd..6d0ac252 100644 --- a/config/webpack/rscWebpackConfig.js +++ b/config/webpack/rscWebpackConfig.js @@ -8,16 +8,29 @@ const configureRsc = () => { }; rscConfig.entry = rscEntry; - // Runs before babel-loader (webpack loaders execute right-to-left) to - // detect 'use client' directives in raw source before transpilation. + // Runs before babel/swc (webpack loaders execute right-to-left) to detect + // 'use client' directives in raw source before transpilation. Shakapacker + // generates rule.use as an array for Babel and as a function for SWC, so + // handle both forms. const { rules } = rscConfig.module; rules.forEach((rule) => { - if (Array.isArray(rule.use)) { - const babelLoader = extractLoader(rule, 'babel-loader'); - if (babelLoader) { - rule.use.push({ - loader: 'react-on-rails-rsc/WebpackLoader', - }); + if (typeof rule.use === 'function') { + const originalUse = rule.use; + rule.use = function rscLoaderWrapper(data) { + const result = originalUse.call(this, data); + const resultArray = Array.isArray(result) ? result : result ? [result] : []; + const resolvedRule = { use: resultArray }; + const jsLoader = + extractLoader(resolvedRule, 'babel-loader') || extractLoader(resolvedRule, 'swc-loader'); + if (jsLoader) { + return [...resultArray, { loader: 'react-on-rails-rsc/WebpackLoader' }]; + } + return result; + }; + } else if (Array.isArray(rule.use)) { + const jsLoader = extractLoader(rule, 'babel-loader') || extractLoader(rule, 'swc-loader'); + if (jsLoader) { + rule.use = [...rule.use, { loader: 'react-on-rails-rsc/WebpackLoader' }]; } } }); From 1ac1b27e96009da0092027680a1b8dee31b0c39d Mon Sep 17 00:00:00 2001 From: ihabadham Date: Fri, 24 Apr 2026 23:00:08 +0300 Subject: [PATCH 12/21] Disable stubTimers in Pro Node renderer for RSC streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer's stubTimers default of true replaces setTimeout with a no-op inside the VM. React's RSC server renderer uses setTimeout internally for Flight-protocol yielding, so stubbing it makes the RSC stream open without ever emitting a chunk. The request reaches the worker, the worker holds the accepted socket, but no data flows. Fastify eventually closes the idle connection at keepAliveTimeout (~72s), HTTPX retries once by its retries plugin, and Rails sees HTTPX::Connection::HTTP2::GoawayError after ~144s. Non-RSC SSR is unaffected because it doesn't rely on setTimeout for its async scheduling — only RSC's streaming path hits this. Verified by running a second renderer alongside on another port with RENDERER_STUB_TIMERS=false: the stuck path returned a full 9.7KB RSC payload for ServerComponentsPage in 422ms, vs. the default renderer timing out on the same request. Co-Authored-By: Claude Opus 4.7 (1M context) --- renderer/node-renderer.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/renderer/node-renderer.js b/renderer/node-renderer.js index a4cb3a34..9a5f98f4 100644 --- a/renderer/node-renderer.js +++ b/renderer/node-renderer.js @@ -41,6 +41,12 @@ const config = { // deps rely on during SSR. Without URL, react-router-dom's NavLink throws // `ReferenceError: URL is not defined` via encodeLocation. additionalContext: { URL, AbortController }, + // RSC requires a real setTimeout. The renderer's default stubTimers:true + // replaces setTimeout with a no-op to prevent legacy SSR from leaking + // timers, but React's RSC server renderer uses setTimeout internally for + // Flight-protocol yielding — with it stubbed, the RSC stream silently + // emits zero chunks and hangs until the Fastify idle timeout fires. + stubTimers: false, }; reactOnRailsProNodeRenderer(config); From fd9faf10e76116910bde1f62e846486a3b0a2898 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sat, 25 Apr 2026 01:05:34 +0300 Subject: [PATCH 13/21] Enable replayServerAsyncOperationLogs on Pro Node renderer Without this flag, console.* calls made inside async Server Components are captured by the renderer's per-request sharedConsoleHistory but not replayed back to Rails' logs. Any error-path logging from an async component (for example, a catch block that console.errors before returning an error fallback div) disappears, making runtime failures invisible. The generator template, RORP spec dummy, and every maintained RSC demo set this to true for the same reason. Co-Authored-By: Claude Opus 4.7 (1M context) --- renderer/node-renderer.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/renderer/node-renderer.js b/renderer/node-renderer.js index 9a5f98f4..b29a5d03 100644 --- a/renderer/node-renderer.js +++ b/renderer/node-renderer.js @@ -47,6 +47,11 @@ const config = { // Flight-protocol yielding — with it stubbed, the RSC stream silently // emits zero chunks and hangs until the Fastify idle timeout fires. stubTimers: false, + // Surface console output from async server-component code. Without this, + // `console.error` calls from within async Server Components (e.g. + // CommentsFeed's catch block) are silently dropped by the VM, making + // runtime failures in RSC components invisible. + replayServerAsyncOperationLogs: true, }; reactOnRailsProNodeRenderer(config); From 9e17d033af189ad314e59caf9730edf6670cc4c9 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sat, 25 Apr 2026 18:58:34 +0300 Subject: [PATCH 14/21] Refactor RSC demo to canonical RoR Pro data flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/controllers/pages_controller.rb | 7 ++- app/views/pages/server_components.html.erb | 5 +- .../components/CommentsFeed.jsx | 61 +++---------------- .../components/ServerInfo.jsx | 2 +- .../ror_components/ServerComponentsPage.jsx | 4 +- 5 files changed, 20 insertions(+), 59 deletions(-) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index f435f04e..42f0de3a 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -2,6 +2,7 @@ class PagesController < ApplicationController include ReactOnRails::Controller + include ReactOnRailsPro::Stream before_action :set_comments def index @@ -38,7 +39,11 @@ def simple; end def rescript; end - def server_components; end + def server_components + @server_components_comments = Comment.order(id: :desc).limit(10) + .as_json(only: %i[id author text created_at]) + stream_view_containing_react_components(template: "/pages/server_components") + end private diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb index 582e5c11..0a3efa75 100644 --- a/app/views/pages/server_components.html.erb +++ b/app/views/pages/server_components.html.erb @@ -1,5 +1,6 @@ -<%= react_component("ServerComponentsPage", - prerender: false, +<%= stream_react_component("ServerComponentsPage", + props: { comments: @server_components_comments }, + prerender: true, auto_load_bundle: true, trace: Rails.env.development?, id: "ServerComponentsPage-react-component-0") %> diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx index ce2a80ee..f32bff7f 100644 --- a/client/app/bundles/server-components/components/CommentsFeed.jsx +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -1,65 +1,22 @@ -// 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'); -} - -async function CommentsFeed() { - // Simulate network latency only when explicitly enabled for demos. - if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') { +// Default-on small delay so the surrounding fallback is visible +// in the demo. Set RSC_SUSPENSE_DEMO_DELAY=false to disable (CI / tests). +async function CommentsFeed({ comments = [] }) { + if (process.env.RSC_SUSPENSE_DEMO_DELAY !== 'false') { 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); - 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 ( -
    -

    Could not load comments right now. Please try again later.

    -
    - ); - } - - if (recentComments.length === 0) { + if (comments.length === 0) { return (

    @@ -75,10 +32,8 @@ async function CommentsFeed() { return (

    - {recentComments.map((comment) => { - // 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. + {comments.map((comment) => { + // marked + sanitize-html (~200KB combined) stay server-side. const rawHtml = marked.parse(comment.text || ''); const safeHtml = sanitizeHtml(rawHtml, { allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), @@ -119,7 +74,7 @@ async function CommentsFeed() { ); })}

    - {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '} + {comments.length} comment{comments.length !== 1 ? 's' : ''} rendered on the server using{' '} marked + sanitize-html (never sent to browser)

    diff --git a/client/app/bundles/server-components/components/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx index 4475eb82..b85073f6 100644 --- a/client/app/bundles/server-components/components/ServerInfo.jsx +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -5,7 +5,7 @@ import React from 'react'; import os from 'os'; import _ from 'lodash'; -async function ServerInfo() { +function ServerInfo() { const serverInfo = { platform: os.platform(), arch: os.arch(), diff --git a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx index 22c53fc2..abc0b5a5 100644 --- a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -7,7 +7,7 @@ import ServerInfo from '../components/ServerInfo'; import CommentsFeed from '../components/CommentsFeed'; import TogglePanel from '../components/TogglePanel'; -const ServerComponentsPage = () => { +const ServerComponentsPage = ({ comments = [] }) => { return (
    @@ -81,7 +81,7 @@ const ServerComponentsPage = () => {
    } > - + From df039e22c86d0905097f6c98fa7d89b59c4666fb Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sat, 25 Apr 2026 19:39:10 +0300 Subject: [PATCH 15/21] Add RSCRoute + ErrorBoundary demo to RSC page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates two RSC capabilities the demo previously didn't cover: 1. Client-fetched server components via `RSCRoute` — a new section "Live Server Activity" shows server-side data (timestamp, free RAM, uptime). A Refresh button bumps the props' refreshKey, causing `RSCRoute` to fetch a fresh RSC payload over HTTP. No client-side fetcher code, no JSON parsing, no loading-state plumbing — the server re-renders, streams the result back, and React reconciles. 2. Error handling per the docs' canonical pattern (`rsc-troubleshooting.md` "Error Boundary Limitations"): a Simulate Error button forces the server component to throw. The error surfaces on the client as `ServerComponentFetchError`, is caught by `` from `react-error-boundary`, and the fallback UI exposes a Retry button that re-fetches with corrected props. `LiveActivity.jsx` lives in `ror_components/` so auto_load_bundle registers it as a Server Component reachable by `RSCRoute` via name. `LiveActivityRefresher.jsx` is a single-file Client Component (no `.client/.server` wrapper pair needed) — registerServerComponent on the outer `ServerComponentsPage` provides the RSC context for `RSCRoute` calls anywhere in its subtree. Also restores `updated_at` to the comments prop set so the controller matches the canonical `_comment.json.jbuilder` partial's field shape (reverting a UI-driven minimization that coupled the resource representation to today's component code). Adds: `react-error-boundary@^4.1.2`. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/controllers/pages_controller.rb | 2 +- .../components/LiveActivityRefresher.jsx | 72 +++++++++++++++++++ .../ror_components/LiveActivity.jsx | 46 ++++++++++++ .../ror_components/ServerComponentsPage.jsx | 25 ++++++- package.json | 1 + yarn.lock | 7 ++ 6 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 client/app/bundles/server-components/components/LiveActivityRefresher.jsx create mode 100644 client/app/bundles/server-components/ror_components/LiveActivity.jsx diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 42f0de3a..3a95723c 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -41,7 +41,7 @@ def rescript; end def server_components @server_components_comments = Comment.order(id: :desc).limit(10) - .as_json(only: %i[id author text created_at]) + .as_json(only: %i[id author text created_at updated_at]) stream_view_containing_react_components(template: "/pages/server_components") end diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx new file mode 100644 index 00000000..98ce5a9f --- /dev/null +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -0,0 +1,72 @@ +'use client'; + +import React, { useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import RSCRoute from 'react-on-rails-pro/RSCRoute'; + +function ErrorFallback({ error, resetErrorBoundary }) { + return ( +
    +

    Server component fetch failed

    +

    {error.message}

    + +
    + ); +} + +const LiveActivityRefresher = () => { + const [refreshKey, setRefreshKey] = useState(0); + const [simulateError, setSimulateError] = useState(false); + + const handleRefresh = () => { + setSimulateError(false); + setRefreshKey((k) => k + 1); + }; + + const handleSimulateError = () => { + setSimulateError(true); + setRefreshKey((k) => k + 1); + }; + + return ( +
    +
    + + + + Refresh count: {refreshKey} + +
    + { + setSimulateError(false); + setRefreshKey((k) => k + 1); + }} + resetKeys={[refreshKey]} + > + + +
    + ); +}; + +export default LiveActivityRefresher; diff --git a/client/app/bundles/server-components/ror_components/LiveActivity.jsx b/client/app/bundles/server-components/ror_components/LiveActivity.jsx new file mode 100644 index 00000000..91ac1c67 --- /dev/null +++ b/client/app/bundles/server-components/ror_components/LiveActivity.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import os from 'os'; + +async function LiveActivity({ simulateError = false }) { + if (simulateError) { + throw new Error('Simulated server-side render failure (demo)'); + } + + // Small delay so the refresh-in-flight state is visible. + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + + const stats = { + serverTime: new Date().toISOString(), + freeMemoryMB: Math.round(os.freemem() / (1024 * 1024)), + uptimeHours: Math.floor(os.uptime() / 3600), + }; + + return ( +
    +
    +
    +
    + Server Time +
    +
    {stats.serverTime}
    +
    +
    +
    + Free RAM +
    +
    {stats.freeMemoryMB} MB
    +
    +
    +
    + Uptime (hrs) +
    +
    {stats.uptimeHours}
    +
    +
    +
    + ); +} + +export default LiveActivity; diff --git a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx index abc0b5a5..9729394c 100644 --- a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -6,6 +6,7 @@ import React, { Suspense } from 'react'; import ServerInfo from '../components/ServerInfo'; import CommentsFeed from '../components/CommentsFeed'; import TogglePanel from '../components/TogglePanel'; +import LiveActivityRefresher from '../components/LiveActivityRefresher'; const ServerComponentsPage = ({ comments = [] }) => { return ( @@ -57,6 +58,25 @@ const ServerComponentsPage = ({ comments = [] }) => { + {/* Client-fetched server component via RSCRoute + ErrorBoundary */} +
    +

    + Live Server Activity + + RSCRoute + ErrorBoundary + +

    +

    + Click Refresh to fetch a new RSC payload — the server re-renders + this section and streams the result back, no client-side JSON parsing or loading + state plumbing. Click Simulate Error to make the server component + throw; the failure surfaces as ServerComponentFetchError and is + caught by <ErrorBoundary>, which renders a Retry button that + re-fetches with corrected props. +

    + +
    + {/* Async data fetching with Suspense streaming */}

    @@ -66,8 +86,9 @@ const ServerComponentsPage = ({ comments = [] }) => {

    - Comments are fetched directly on the server using the Rails API. - The page shell renders immediately while this section streams in progressively. + Comments come from the Rails controller as props — the canonical React on Rails Pro + pattern. The page shell renders immediately while this section streams in + progressively as Suspense boundaries resolve.

    Date: Sat, 25 Apr 2026 19:43:40 +0300 Subject: [PATCH 16/21] Use refetchComponent explicitly in retry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit's retry path used resetErrorBoundary + onReset state mutation alone — RSCRoute would auto-fetch on the post-reset render because props had changed. Functional, but didn't actually demonstrate useRSC().refetchComponent, which is the canonical RoR Pro RSC retry API per rsc-troubleshooting.md. Switch to: refetchComponent('LiveActivity', correctedProps) primes the cache, then resetErrorBoundary fires; the post-reset render hits cache instead of triggering a second fetch. This shows the explicit API call the docs recommend, while still handling our intentional-error case (refetching with simulateError=false instead of the original throwing props). Page description updated to mention refetchComponent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/LiveActivityRefresher.jsx | 52 ++++++++++--------- .../ror_components/ServerComponentsPage.jsx | 2 +- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx index 98ce5a9f..b5e97879 100644 --- a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -3,26 +3,12 @@ import React, { useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import RSCRoute from 'react-on-rails-pro/RSCRoute'; - -function ErrorFallback({ error, resetErrorBoundary }) { - return ( -
    -

    Server component fetch failed

    -

    {error.message}

    - -
    - ); -} +import { useRSC } from 'react-on-rails-pro/RSCProvider'; const LiveActivityRefresher = () => { const [refreshKey, setRefreshKey] = useState(0); const [simulateError, setSimulateError] = useState(false); + const { refetchComponent } = useRSC(); const handleRefresh = () => { setSimulateError(false); @@ -34,6 +20,18 @@ const LiveActivityRefresher = () => { 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; + setSimulateError(false); + setRefreshKey(newKey); + refetchComponent('LiveActivity', { simulateError: false, refreshKey: newKey }) + // eslint-disable-next-line no-console + .catch((err) => console.error('Retry refetch failed:', err)) + .finally(() => resetErrorBoundary()); + }; + return (
    @@ -51,16 +49,22 @@ const LiveActivityRefresher = () => { > Simulate Error - - Refresh count: {refreshKey} - + Refresh count: {refreshKey}
    { - setSimulateError(false); - setRefreshKey((k) => k + 1); - }} + fallbackRender={({ error, resetErrorBoundary }) => ( +
    +

    Server component fetch failed

    +

    {error.message}

    + +
    + )} resetKeys={[refreshKey]} > diff --git a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx index 9729394c..e6b8df84 100644 --- a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -72,7 +72,7 @@ const ServerComponentsPage = ({ comments = [] }) => { state plumbing. Click Simulate Error to make the server component throw; the failure surfaces as ServerComponentFetchError and is caught by <ErrorBoundary>, which renders a Retry button that - re-fetches with corrected props. + calls refetchComponent with corrected props.

    From 0b626bc7011fec3247f8382596e55d4d4e0c4eb8 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sun, 26 Apr 2026 19:12:11 +0300 Subject: [PATCH 17/21] Address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pages_controller.rb: scope `before_action :set_comments` to actions that use @comments (index, no_router) — server_components/simple/rescript no longer trigger an unused query. - server_components.html.erb: drop hardcoded `id:` from stream_react_component call. RoR auto-generates stable unique IDs; hardcoding risks DOM-id collisions if the helper is ever called twice and diverges from the default pattern used by /no_router and /simple. - CommentsFeed.jsx: flip RSC_SUSPENSE_DEMO_DELAY to opt-in (=== 'true'). The previous opt-out guard (!== 'false') made the 800ms delay fire by default in any deploy without the env var explicitly set. app.yml: add RSC_SUSPENSE_DEMO_DELAY=true to the review-app template so the demo still shows the streaming fallback visibly. - ServerInfo.jsx: drop the Hostname row. The K8s pod name (e.g. rails-5fc66bddf6-r8b5r) is infrastructure-fingerprinting fodder for forks deployed publicly, and ServerInfo's "look, server-side data" intent is fully covered by the remaining six fields. - stores-registration.js: add a one-line comment noting why 'use client' sits on a webpack pack entry (excludes the registration code's dependency graph from the RSC bundle). Co-Authored-By: Claude Opus 4.7 (1M context) --- .controlplane/templates/app.yml | 4 ++++ app/controllers/pages_controller.rb | 2 +- app/views/pages/server_components.html.erb | 3 +-- .../bundles/server-components/components/CommentsFeed.jsx | 6 +++--- .../app/bundles/server-components/components/ServerInfo.jsx | 2 -- client/app/packs/stores-registration.js | 1 + 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.controlplane/templates/app.yml b/.controlplane/templates/app.yml index 0ff2ac87..5ed7c689 100644 --- a/.controlplane/templates/app.yml +++ b/.controlplane/templates/app.yml @@ -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' # 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 diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 3a95723c..d668bd8c 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -3,7 +3,7 @@ class PagesController < ApplicationController include ReactOnRails::Controller include ReactOnRailsPro::Stream - before_action :set_comments + 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 diff --git a/app/views/pages/server_components.html.erb b/app/views/pages/server_components.html.erb index 0a3efa75..029cfce1 100644 --- a/app/views/pages/server_components.html.erb +++ b/app/views/pages/server_components.html.erb @@ -2,5 +2,4 @@ props: { comments: @server_components_comments }, prerender: true, auto_load_bundle: true, - trace: Rails.env.development?, - id: "ServerComponentsPage-react-component-0") %> + trace: Rails.env.development?) %> diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx index f32bff7f..76129b33 100644 --- a/client/app/bundles/server-components/components/CommentsFeed.jsx +++ b/client/app/bundles/server-components/components/CommentsFeed.jsx @@ -7,10 +7,10 @@ import TogglePanel from './TogglePanel'; const marked = new Marked(); marked.use(gfmHeadingId()); -// Default-on small delay so the surrounding fallback is visible -// in the demo. Set RSC_SUSPENSE_DEMO_DELAY=false to disable (CI / tests). +// Opt-in delay so the surrounding 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 !== 'false') { + if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') { await new Promise((resolve) => { setTimeout(resolve, 800); }); diff --git a/client/app/bundles/server-components/components/ServerInfo.jsx b/client/app/bundles/server-components/components/ServerInfo.jsx index b85073f6..e09fa1d9 100644 --- a/client/app/bundles/server-components/components/ServerInfo.jsx +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -14,7 +14,6 @@ function ServerInfo() { totalMemory: (os.totalmem() / (1024 * 1024 * 1024)).toFixed(1), freeMemory: (os.freemem() / (1024 * 1024 * 1024)).toFixed(1), cpus: os.cpus().length, - hostname: os.hostname(), }; // Using lodash on the server — this 70KB+ library stays server-side @@ -29,7 +28,6 @@ function ServerInfo() { totalMemory: 'Total RAM (GB)', freeMemory: 'Free RAM (GB)', cpus: 'CPU Cores', - hostname: 'Hostname', }; return ( diff --git a/client/app/packs/stores-registration.js b/client/app/packs/stores-registration.js index cecf0a95..a069ac62 100644 --- a/client/app/packs/stores-registration.js +++ b/client/app/packs/stores-registration.js @@ -1,3 +1,4 @@ +// 'use client' keeps this pack and its store imports out of the RSC bundle. 'use client'; import ReactOnRails from 'react-on-rails-pro'; From 09b113ec2fd2a0d6a13d9948831839bce7199120 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sun, 26 Apr 2026 19:34:11 +0300 Subject: [PATCH 18/21] Add request + system specs for the RSC demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request spec mirrors the dummy's `requests/rsc_payload_spec.rb` pattern (NDJSON parsing, html-chunk presence). Covers: - GET /server-components returns the demo page shell - /rsc_payload/ServerComponentsPage streams a valid NDJSON payload - /rsc_payload/LiveActivity streams a valid NDJSON payload System spec covers the user-facing behaviors of the new sections: - Page renders the four demo section headings - ServerInfo labels appear (Platform, Architecture, Node.js, CPU Cores) - Live Activity shows the live stats labels + initial Refresh count - Refresh button increments the counter (RSCRoute fetch happens) - Simulate Error → ErrorBoundary fallback → Retry recovers Tests behaviors only — no internal state assertions, no specific values (server time, RAM number) that would be flaky. Uses existing rails_helper infrastructure (headless Chrome via DriverRegistration, Capybara defaults). Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/requests/server_components_spec.rb | 40 ++++++++++++++++++++ spec/system/server_components_demo_spec.rb | 44 ++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 spec/requests/server_components_spec.rb create mode 100644 spec/system/server_components_demo_spec.rb diff --git a/spec/requests/server_components_spec.rb b/spec/requests/server_components_spec.rb new file mode 100644 index 00000000..80c45031 --- /dev/null +++ b/spec/requests/server_components_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Server Components" do + it "GET /server-components returns the demo page shell" do + get "/server-components" + expect(response).to have_http_status(:ok) + expect(response.body).to include("React Server Components Demo") + end + + describe "RSC payload endpoint" do + def parsed_chunks + response.body.each_line.filter_map do |line| + stripped = line.strip + next if stripped.empty? + + JSON.parse(stripped) + end + end + + def expect_valid_rsc_payload + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("application/x-ndjson") + chunks = parsed_chunks + expect(chunks).not_to be_empty + expect(chunks.any? { |chunk| chunk.key?("html") }).to be(true) + end + + it "streams a valid RSC payload for ServerComponentsPage" do + get "/rsc_payload/ServerComponentsPage", params: { props: "{}" } + expect_valid_rsc_payload + end + + it "streams a valid RSC payload for LiveActivity" do + get "/rsc_payload/LiveActivity", params: { props: "{}" } + expect_valid_rsc_payload + end + end +end diff --git a/spec/system/server_components_demo_spec.rb b/spec/system/server_components_demo_spec.rb new file mode 100644 index 00000000..6361fc68 --- /dev/null +++ b/spec/system/server_components_demo_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Server Components demo" do + before { visit "/server-components" } + + it "renders the four demo sections" do + expect(page).to have_selector("h2", text: "Server Environment") + expect(page).to have_selector("h2", text: "Interactive Client Component") + expect(page).to have_selector("h2", text: "Live Server Activity") + expect(page).to have_selector("h2", text: "Streamed Comments") + end + + it "shows server-side data in ServerInfo" do + expect(page).to have_content("Platform") + expect(page).to have_content("Architecture") + expect(page).to have_content("Node.js") + expect(page).to have_content("CPU Cores") + end + + describe "Live Server Activity (RSCRoute)" do + it "shows the initial activity card with the live stats labels" do + expect(page).to have_content("SERVER TIME") + expect(page).to have_content("FREE RAM") + expect(page).to have_content("UPTIME (HRS)") + expect(page).to have_content("Refresh count: 0") + end + + it "updates content when Refresh is clicked" do + click_button "Refresh" + expect(page).to have_content("Refresh count: 1") + end + + it "shows the ErrorBoundary fallback when Simulate Error is clicked, then recovers on Retry" do + click_button "Simulate Error" + expect(page).to have_content("Server component fetch failed") + + click_button "Retry" + expect(page).to have_content("SERVER TIME") + expect(page).to have_no_content("Server component fetch failed") + end + end +end From f23bebaa9b47fe0ebb260dd0d3e25fd98d11cfa7 Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sun, 26 Apr 2026 19:58:36 +0300 Subject: [PATCH 19/21] Gate LiveActivity 300ms delay on RSC_SUSPENSE_DEMO_DELAY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setTimeout was firing on every request, including production deployments — verified via curl on the review-app: /rsc_payload/ LiveActivity took ~1000ms vs ~620ms for /rsc_payload/ServerComponentsPage across 3 trials, ~400ms gap matching the unconditional 300ms delay. Same opt-in gate as CommentsFeed: enabled when env var is exactly 'true', off by default. Review-app keeps the visible behavior because app.yml sets RSC_SUSPENSE_DEMO_DELAY=true. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server-components/ror_components/LiveActivity.jsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/app/bundles/server-components/ror_components/LiveActivity.jsx b/client/app/bundles/server-components/ror_components/LiveActivity.jsx index 91ac1c67..a76f7de3 100644 --- a/client/app/bundles/server-components/ror_components/LiveActivity.jsx +++ b/client/app/bundles/server-components/ror_components/LiveActivity.jsx @@ -6,10 +6,13 @@ async function LiveActivity({ simulateError = false }) { throw new Error('Simulated server-side render failure (demo)'); } - // Small delay so the refresh-in-flight state is visible. - await new Promise((resolve) => { - setTimeout(resolve, 300); - }); + // Opt-in delay so the refresh-in-flight state is visible in the demo. + // Matches the gate in CommentsFeed; off by default in production. + if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') { + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + } const stats = { serverTime: new Date().toISOString(), From e2c76a5ad243d2875f34c3494306a6764da6bf1e Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sun, 26 Apr 2026 20:03:45 +0300 Subject: [PATCH 20/21] Wrap RSCRoute in local Suspense to prevent whole-page collapse Without a local Suspense boundary, RSCRoute's in-flight fetch suspension bubbles up to whatever outer Suspense the Pro stream_react_component infrastructure provides. That outer fallback wipes the entire rendered tree, so during a Refresh or Simulate Error click, the whole page collapsed to viewport height for ~500ms and the browser snapped scrollY to 0 (since the page wasn't tall enough to preserve the prior scroll position). Verified empirically via window.scrollY + document.body.scrollHeight sampling on the deployed review-app: pre-fix, pageHeight went from 2193px to 764px between +50ms and +500ms after click. Local Suspense boundary contains the suspension to the LiveActivity section; same- shape skeleton fallback keeps the section's height stable so the surrounding layout doesn't shift. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/LiveActivityRefresher.jsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx index b5e97879..0c716533 100644 --- a/client/app/bundles/server-components/components/LiveActivityRefresher.jsx +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -1,10 +1,28 @@ 'use client'; -import React, { useState } from 'react'; +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 = () => ( +
    +
    + {['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => ( +
    +
    + {label} +
    +
    +
    + ))} +
    +
    +); + const LiveActivityRefresher = () => { const [refreshKey, setRefreshKey] = useState(0); const [simulateError, setSimulateError] = useState(false); @@ -67,7 +85,9 @@ const LiveActivityRefresher = () => { )} resetKeys={[refreshKey]} > - + }> + +
    ); From 86eed7a5b78d35b3a4c1a3b5171032d26c0f504b Mon Sep 17 00:00:00 2001 From: ihabadham Date: Sun, 26 Apr 2026 20:59:41 +0300 Subject: [PATCH 21/21] Add request spec variant exercising populated CommentsFeed Existing /rsc_payload/ServerComponentsPage spec sent empty {} props, which only exercises the empty-state branch of CommentsFeed. The new variant passes a realistic comment so the marked + sanitize-html markdown rendering path and the comment list mapping are covered. Verified locally: 4/4 examples pass against the running renderer. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/requests/server_components_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/requests/server_components_spec.rb b/spec/requests/server_components_spec.rb index 80c45031..5527c218 100644 --- a/spec/requests/server_components_spec.rb +++ b/spec/requests/server_components_spec.rb @@ -32,6 +32,15 @@ def expect_valid_rsc_payload expect_valid_rsc_payload end + it "streams a valid RSC payload for ServerComponentsPage with populated comments" do + now = 1.minute.ago.iso8601 + comments = [ + { id: 1, author: "Alice", text: "Hello **markdown**", created_at: now, updated_at: now }, + ] + get "/rsc_payload/ServerComponentsPage", params: { props: { comments: comments }.to_json } + expect_valid_rsc_payload + end + it "streams a valid RSC payload for LiveActivity" do get "/rsc_payload/LiveActivity", params: { props: "{}" } expect_valid_rsc_payload