diff --git a/Procfile.dev b/Procfile.dev index 20cd0f7b6..71c6a4b13 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/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 507cc6cf7..f435f04eb 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 000000000..582e5c11f --- /dev/null +++ b/app/views/pages/server_components.html.erb @@ -0,0 +1,5 @@ +<%= react_component("ServerComponentsPage", + prerender: false, + auto_load_bundle: true, + trace: Rails.env.development?, + id: "ServerComponentsPage-react-component-0") %> 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 5e7f42104..d153dfb22 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/NavigationBar/NavigationBar.jsx b/client/app/bundles/comments/components/NavigationBar/NavigationBar.jsx index db2b4e53c..30b99f371 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 + +
  • { + 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 000000000..4475eb826 --- /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 000000000..1336b56b3 --- /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; diff --git a/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx new file mode 100644 index 000000000..22c53fc23 --- /dev/null +++ b/client/app/bundles/server-components/ror_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/packs/stimulus-bundle.js b/client/app/packs/stimulus-bundle.js index 2664fee2b..07dedc01f 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 d03732dc3..cecf0a958 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'; diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb index 4e4f03890..fc5f39000 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 diff --git a/config/routes.rb b/config/routes.rb index 1d8c7b7a5..353819a32 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" diff --git a/config/webpack/clientWebpackConfig.js b/config/webpack/clientWebpackConfig.js index 6352208fb..ea5957c0f 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; }; diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js new file mode 100644 index 000000000..6d0ac252c --- /dev/null +++ b/config/webpack/rscWebpackConfig.js @@ -0,0 +1,53 @@ +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/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 (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' }]; + } + } + }); + + 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; diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js index 44327e8b1..b1783513a 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,11 +23,14 @@ 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 - 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" diff --git a/renderer/node-renderer.js b/renderer/node-renderer.js index a4cb3a347..b29a5d032 100644 --- a/renderer/node-renderer.js +++ b/renderer/node-renderer.js @@ -41,6 +41,17 @@ 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, + // 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);