diff --git a/.controlplane/templates/app.yml b/.controlplane/templates/app.yml index 0ff2ac87c..5ed7c689f 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/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..d668bd8cd 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -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 @@ -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") + 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..029cfce1b --- /dev/null +++ b/app/views/pages/server_components.html.erb @@ -0,0 +1,5 @@ +<%= stream_react_component("ServerComponentsPage", + props: { comments: @server_components_comments }, + prerender: true, + auto_load_bundle: true, + trace: Rails.env.development?) %> 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 + +
  • 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 ( +
    +

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

    +
    + ); + } + + return ( +
    + {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']), + 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}

    +
    + ); + })} +

    + {comments.length} comment{comments.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/LiveActivityRefresher.jsx b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx new file mode 100644 index 000000000..0c7165339 --- /dev/null +++ b/client/app/bundles/server-components/components/LiveActivityRefresher.jsx @@ -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 = () => ( +
    +
    + {['Server Time', 'Free RAM', 'Uptime (hrs)'].map((label) => ( +
    +
    + {label} +
    +
    +
    + ))} +
    +
    +); + +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; + 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 ( +
    +
    + + + Refresh count: {refreshKey} +
    + ( +
    +

    Server component fetch failed

    +

    {error.message}

    + +
    + )} + resetKeys={[refreshKey]} + > + }> + + +
    +
    + ); +}; + +export default LiveActivityRefresher; 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..e09fa1d98 --- /dev/null +++ b/client/app/bundles/server-components/components/ServerInfo.jsx @@ -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 ( +
    +

    + 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/LiveActivity.jsx b/client/app/bundles/server-components/ror_components/LiveActivity.jsx new file mode 100644 index 000000000..a76f7de34 --- /dev/null +++ b/client/app/bundles/server-components/ror_components/LiveActivity.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import os from 'os'; + +async function LiveActivity({ simulateError = false }) { + if (simulateError) { + throw new Error('Simulated server-side render failure (demo)'); + } + + // 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(), + 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 new file mode 100644 index 000000000..e6b8df849 --- /dev/null +++ b/client/app/bundles/server-components/ror_components/ServerComponentsPage.jsx @@ -0,0 +1,150 @@ +// 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'; +import LiveActivityRefresher from '../components/LiveActivityRefresher'; + +const ServerComponentsPage = ({ comments = [] }) => { + 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
    • +
    +
    +
    +
    + + {/* 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 + calls refetchComponent with corrected props. +

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

    + Streamed Comments + + Async + Suspense + +

    +

    + 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. +

    + + {[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..a069ac620 100644 --- a/client/app/packs/stores-registration.js +++ b/client/app/packs/stores-registration.js @@ -1,3 +1,6 @@ +// 'use client' keeps this pack and its store imports out of the RSC bundle. +'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/package.json b/package.json index 1ae8c2c79..3f2801592 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "prop-types": "^15.8.1", "react": "~19.0.4", "react-dom": "~19.0.4", + "react-error-boundary": "^4.1.2", "react-intl": "^6.4.4", "react-on-rails-pro": "16.6.0", "react-on-rails-pro-node-renderer": "16.6.0", 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); diff --git a/spec/requests/server_components_spec.rb b/spec/requests/server_components_spec.rb new file mode 100644 index 000000000..5527c218f --- /dev/null +++ b/spec/requests/server_components_spec.rb @@ -0,0 +1,49 @@ +# 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 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 + 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 000000000..6361fc680 --- /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 diff --git a/yarn.lock b/yarn.lock index 833bca340..d1e20864a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8745,6 +8745,13 @@ react-dom@~19.0.4: dependencies: scheduler "^0.25.0" +react-error-boundary@^4.1.2: + version "4.1.2" + resolved "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz#bc750ad962edb8b135d6ae922c046051eb58f289" + integrity sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag== + dependencies: + "@babel/runtime" "^7.12.5" + react-intl@^6.4.4: version "6.8.9" resolved "https://registry.npmjs.org/react-intl/-/react-intl-6.8.9.tgz"