+
+ {/* Content is sanitized via sanitize-html before rendering */}
+
+
+
{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..7c059e3c7
--- /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.
+
No comments yet. Add some comments from the{' '}
- home page to see them rendered here
- by server components.
+
+ home page
+ {' '}
+ to see them rendered here by server components.
- {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using
- {' '}marked + sanitize-html (never sent to browser)
+ {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '}
+ marked + sanitize-html (never sent to browser)
);
diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb
index e5ab7965a..c31b87ab1 100644
--- a/config/initializers/react_on_rails_pro.rb
+++ b/config/initializers/react_on_rails_pro.rb
@@ -2,9 +2,20 @@
ReactOnRailsPro.configure do |config|
# Node renderer for server-side rendering and RSC payload generation
- config.server_renderer = "NodeRenderer"
- config.renderer_url = ENV["REACT_RENDERER_URL"] || "http://localhost:3800"
- config.renderer_password = ENV.fetch("RENDERER_PASSWORD", "devPassword")
+ use_node_renderer = Rails.env.development? || ENV["REACT_USE_NODE_RENDERER"] == "true"
+
+ if use_node_renderer
+ renderer_host = ENV.fetch("RENDERER_HOST", "localhost")
+ renderer_port = ENV.fetch("RENDERER_PORT", "3800")
+
+ config.server_renderer = "NodeRenderer"
+ config.renderer_url = ENV.fetch("REACT_RENDERER_URL", "http://#{renderer_host}:#{renderer_port}")
+ config.renderer_password = if Rails.env.local?
+ ENV.fetch("RENDERER_PASSWORD", "local-dev-renderer-password")
+ else
+ ENV.fetch("RENDERER_PASSWORD")
+ end
+ end
# Enable React Server Components support
config.enable_rsc_support = true
diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js
index ebd137cda..83ffb504a 100644
--- a/config/webpack/rscWebpackConfig.js
+++ b/config/webpack/rscWebpackConfig.js
@@ -28,9 +28,7 @@ const configureRsc = () => {
// Use the dedicated rsc-bundle entry point
const rscEntry = rscConfig.entry['rsc-bundle'];
if (!rscEntry) {
- throw new Error(
- 'RSC bundle entry not found. Ensure client/app/packs/rsc-bundle.js exists.',
- );
+ throw new Error('RSC bundle entry not found. Ensure client/app/packs/rsc-bundle.js exists.');
}
rscConfig.entry = { 'rsc-bundle': rscEntry };
@@ -63,8 +61,8 @@ const configureRsc = () => {
cssLoader.options.modules = { ...cssLoader.options.modules, exportOnlyLocals: true };
}
} else if (
- rule.use?.loader
- && (rule.use.loader.includes('url-loader') || rule.use.loader.includes('file-loader'))
+ rule.use?.loader &&
+ (rule.use.loader.includes('url-loader') || rule.use.loader.includes('file-loader'))
) {
rule.use.options = { ...(rule.use.options || {}), emitFile: false };
}
@@ -103,7 +101,7 @@ const configureRsc = () => {
// Target Node.js so server-only modules (os, fs, stream, etc.) resolve correctly
rscConfig.target = 'node';
- rscConfig.devtool = 'eval';
+ rscConfig.devtool = process.env.NODE_ENV === 'production' ? 'source-map' : 'eval';
// RSC manifest plugin
rscConfig.plugins.push(new RspackRscPlugin({ isServer: true }));
diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js
index 1c3c2f659..331cc1170 100644
--- a/config/webpack/rspackRscPlugin.js
+++ b/config/webpack/rspackRscPlugin.js
@@ -9,32 +9,6 @@
const fs = require('fs');
const { sources } = require('@rspack/core');
-// Cache for file 'use client' checks
-const useClientCache = new Map();
-
-function hasUseClientDirective(filePath) {
- if (useClientCache.has(filePath)) return useClientCache.get(filePath);
-
- let result = false;
- try {
- // Read the first ~200 bytes — 'use client' must be at the very top of the file
- const fd = fs.openSync(filePath, 'r');
- const buf = Buffer.alloc(200);
- fs.readSync(fd, buf, 0, 200, 0);
- fs.closeSync(fd);
-
- const head = buf.toString('utf-8');
- // Check for 'use client' as the first statement.
- // Allow comments (single-line // or block /* */) before the directive.
- result = /^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*['"]use client['"]/.test(head);
- } catch (_) {
- // file doesn't exist or can't be read
- }
-
- useClientCache.set(filePath, result);
- return result;
-}
-
class RspackRscPlugin {
constructor(options) {
if (!options || typeof options.isServer !== 'boolean') {
@@ -45,12 +19,13 @@ class RspackRscPlugin {
? 'react-server-client-manifest.json'
: 'react-client-manifest.json';
this.ssrManifestFilename = 'react-ssr-manifest.json';
+ this.useClientCache = new Map();
}
apply(compiler) {
// Clear cache on each compilation so watch-mode picks up 'use client' changes
compiler.hooks.thisCompilation.tap('RspackRscPlugin-ClearCache', () => {
- useClientCache.clear();
+ this.useClientCache.clear();
});
compiler.hooks.thisCompilation.tap('RspackRscPlugin', (compilation) => {
@@ -103,6 +78,36 @@ class RspackRscPlugin {
});
}
+ _hasUseClientDirective(filePath) {
+ if (this.useClientCache.has(filePath)) return this.useClientCache.get(filePath);
+
+ let result = false;
+ let fd;
+ try {
+ // Read the first ~200 bytes — 'use client' must be at the very top of the file.
+ fd = fs.openSync(filePath, 'r');
+ const buf = Buffer.alloc(200);
+ fs.readSync(fd, buf, 0, 200, 0);
+
+ const head = buf.toString('utf-8');
+ // Allow comments before the directive.
+ result = /^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*['"]use client['"]/.test(head);
+ } catch (_) {
+ // file doesn't exist or can't be read
+ } finally {
+ if (fd !== undefined) {
+ try {
+ fs.closeSync(fd);
+ } catch (_) {
+ // no-op
+ }
+ }
+ }
+
+ this.useClientCache.set(filePath, result);
+ return result;
+ }
+
_processModule(mod, chunk, chunkFiles, manifest, compilation) {
const resource = mod.resource || mod.userRequest;
if (!resource || !resource.match(/\.(js|jsx|ts|tsx)$/)) return;
@@ -110,11 +115,9 @@ class RspackRscPlugin {
if (resource.includes('node_modules')) return;
// Check original file for 'use client' directive
- if (!hasUseClientDirective(resource)) return;
+ if (!this._hasUseClientDirective(resource)) return;
- const moduleId = compilation.chunkGraph
- ? compilation.chunkGraph.getModuleId(mod)
- : mod.id;
+ const moduleId = compilation.chunkGraph ? compilation.chunkGraph.getModuleId(mod) : mod.id;
if (moduleId == null) return;
diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js
index 48e98424a..dd20c06d8 100644
--- a/config/webpack/serverWebpackConfig.js
+++ b/config/webpack/serverWebpackConfig.js
@@ -58,13 +58,13 @@ const configureServer = () => {
throw new Error(
`Server bundle entry 'server-bundle' not found.\n` +
- `Expected file: ${fullPath}\n` +
- `Current source_path: ${config.source_path}\n` +
- `Current source_entry_path: ${config.source_entry_path}\n` +
- `Verify:\n` +
- `1. The server-bundle.js file exists at the expected location\n` +
- `2. nested_entries is configured correctly in shakapacker.yml\n` +
- `3. The file is properly exported from your entry point`,
+ `Expected file: ${fullPath}\n` +
+ `Current source_path: ${config.source_path}\n` +
+ `Current source_entry_path: ${config.source_entry_path}\n` +
+ `Verify:\n` +
+ `1. The server-bundle.js file exists at the expected location\n` +
+ `2. nested_entries is configured correctly in shakapacker.yml\n` +
+ `3. The file is properly exported from your entry point`,
);
}
@@ -94,7 +94,7 @@ const configureServer = () => {
serverWebpackConfig.output = {
filename: 'server-bundle.js',
globalObject: 'this',
- // libraryTarget: 'commonjs2',
+ libraryTarget: 'commonjs2',
path: path.resolve(__dirname, '../../ssr-generated'),
publicPath: config.publicPath,
// https://webpack.js.org/configuration/output/#outputglobalobject
@@ -199,4 +199,5 @@ const configureServer = () => {
return serverWebpackConfig;
};
-module.exports = { default: configureServer, extractLoader };
+module.exports = configureServer;
+module.exports.extractLoader = extractLoader;
diff --git a/config/webpack/webpackConfig.js b/config/webpack/webpackConfig.js
index 595fc63c3..df036a145 100644
--- a/config/webpack/webpackConfig.js
+++ b/config/webpack/webpackConfig.js
@@ -2,7 +2,7 @@
// https://github.com/shakacode/react_on_rails_tutorial_with_ssr_and_hmr_fast_refresh/blob/master/config/webpack/webpackConfig.js
const clientWebpackConfig = require('./clientWebpackConfig');
-const { default: serverWebpackConfig } = require('./serverWebpackConfig');
+const serverWebpackConfig = require('./serverWebpackConfig');
const rscWebpackConfig = require('./rscWebpackConfig');
const webpackConfig = (envSpecific) => {
diff --git a/react-on-rails-pro-node-renderer.js b/react-on-rails-pro-node-renderer.js
index 0f728691b..d80045899 100644
--- a/react-on-rails-pro-node-renderer.js
+++ b/react-on-rails-pro-node-renderer.js
@@ -1,10 +1,17 @@
const path = require('path');
const { reactOnRailsProNodeRenderer } = require('react-on-rails-pro-node-renderer');
+const isProduction = process.env.NODE_ENV === 'production';
+const rendererPassword = process.env.RENDERER_PASSWORD || (!isProduction && 'local-dev-renderer-password');
+
+if (!rendererPassword) {
+ throw new Error('RENDERER_PASSWORD must be set in production');
+}
+
const config = {
serverBundleCachePath: path.resolve(__dirname, '.node-renderer-bundles'),
logLevel: process.env.RENDERER_LOG_LEVEL || 'debug',
- password: process.env.RENDERER_PASSWORD || 'devPassword',
+ password: rendererPassword,
port: process.env.RENDERER_PORT || 3800,
supportModules: true,
workersCount: Number(process.env.NODE_RENDERER_CONCURRENCY || 3),
From 248ba65e40e050267a940efbec9107b507448891 Mon Sep 17 00:00:00 2001
From: Justin Gordon
Date: Tue, 7 Apr 2026 13:49:17 -1000
Subject: [PATCH 13/16] Address PR review comments: fix bugs and improve code
quality
- Fix JSON response structure mismatch in CommentsFeed (unwrap data.comments)
- Fix registerServerComponent API call (remove unsupported options object)
- Enable NodeRenderer in test env by using Rails.env.local? instead of development?
- Add warning for non-ENOENT errors in rspackRscPlugin _hasUseClientDirective
- Avoid no-param-reassign ESLint violations in rscWebpackConfig loader pruning
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../components/CommentsFeed.jsx | 3 +-
client/app/packs/stimulus-bundle.js | 5 +---
config/initializers/react_on_rails_pro.rb | 2 +-
config/webpack/rscWebpackConfig.js | 29 +++++++++++--------
config/webpack/rspackRscPlugin.js | 8 +++--
5 files changed, 27 insertions(+), 20 deletions(-)
diff --git a/client/app/bundles/server-components/components/CommentsFeed.jsx b/client/app/bundles/server-components/components/CommentsFeed.jsx
index 1260ffdf8..3914f38c2 100644
--- a/client/app/bundles/server-components/components/CommentsFeed.jsx
+++ b/client/app/bundles/server-components/components/CommentsFeed.jsx
@@ -40,7 +40,8 @@ async function CommentsFeed() {
if (!response.ok) {
throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`);
}
- const comments = await response.json();
+ const data = await response.json();
+ const comments = data.comments;
// Use lodash to process (stays on server)
const sortedComments = _.orderBy(comments, ['created_at'], ['desc']);
diff --git a/client/app/packs/stimulus-bundle.js b/client/app/packs/stimulus-bundle.js
index 920396bab..5517529e4 100644
--- a/client/app/packs/stimulus-bundle.js
+++ b/client/app/packs/stimulus-bundle.js
@@ -22,7 +22,4 @@ ReactOnRails.setOptions({
// No need for manual registration
// React Server Components registration (client-side)
-registerServerComponent(
- { rscPayloadGenerationUrlPath: 'rsc_payload/' },
- 'ServerComponentsPage',
-);
+registerServerComponent('ServerComponentsPage');
diff --git a/config/initializers/react_on_rails_pro.rb b/config/initializers/react_on_rails_pro.rb
index c31b87ab1..2bc8b3506 100644
--- a/config/initializers/react_on_rails_pro.rb
+++ b/config/initializers/react_on_rails_pro.rb
@@ -2,7 +2,7 @@
ReactOnRailsPro.configure do |config|
# Node renderer for server-side rendering and RSC payload generation
- use_node_renderer = Rails.env.development? || ENV["REACT_USE_NODE_RENDERER"] == "true"
+ use_node_renderer = Rails.env.local? || ENV["REACT_USE_NODE_RENDERER"] == "true"
if use_node_renderer
renderer_host = ENV.fetch("RENDERER_HOST", "localhost")
diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js
index 83ffb504a..a838a01e5 100644
--- a/config/webpack/rscWebpackConfig.js
+++ b/config/webpack/rscWebpackConfig.js
@@ -42,9 +42,9 @@ const configureRsc = () => {
);
// Remove CSS extraction loaders from style rules
- rscConfig.module.rules.forEach((rule) => {
+ rscConfig.module.rules = rscConfig.module.rules.map((rule) => {
if (Array.isArray(rule.use)) {
- rule.use = rule.use.filter((item) => {
+ const filteredUse = rule.use.filter((item) => {
const testValue = typeof item === 'string' ? item : item?.loader;
return !(
testValue?.match(/mini-css-extract-plugin/) ||
@@ -53,32 +53,37 @@ const configureRsc = () => {
testValue === 'style-loader'
);
});
- const cssLoader = rule.use.find((item) => {
+ const cssLoader = filteredUse.find((item) => {
const testValue = typeof item === 'string' ? item : item?.loader;
return testValue?.includes('css-loader');
});
- if (cssLoader?.options?.modules) {
- cssLoader.options.modules = { ...cssLoader.options.modules, exportOnlyLocals: true };
- }
- } else if (
+ const updatedCssOptions = cssLoader?.options?.modules
+ ? { ...cssLoader.options, modules: { ...cssLoader.options.modules, exportOnlyLocals: true } }
+ : cssLoader?.options;
+ const updatedUse = updatedCssOptions
+ ? filteredUse.map((item) => (item === cssLoader ? { ...item, options: updatedCssOptions } : item))
+ : filteredUse;
+ return { ...rule, use: updatedUse };
+ }
+ if (
rule.use?.loader &&
(rule.use.loader.includes('url-loader') || rule.use.loader.includes('file-loader'))
) {
- rule.use.options = { ...(rule.use.options || {}), emitFile: false };
+ return { ...rule, use: { ...rule.use, options: { ...(rule.use.options || {}), emitFile: false } } };
}
+ return rule;
});
// Add the RSC WebpackLoader to transpiler rules.
// This loader handles 'use client' directive detection and server/client component separation.
- rscConfig.module.rules.forEach((rule) => {
+ rscConfig.module.rules = rscConfig.module.rules.map((rule) => {
if (Array.isArray(rule.use)) {
const transpilerLoader = extractLoader(rule, 'swc-loader') || extractLoader(rule, 'babel-loader');
if (transpilerLoader) {
- rule.use.push({
- loader: 'react-on-rails-rsc/WebpackLoader',
- });
+ return { ...rule, use: [...rule.use, { loader: 'react-on-rails-rsc/WebpackLoader' }] };
}
}
+ return rule;
});
// Enable react-server condition for server component resolution
diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js
index 331cc1170..b78992492 100644
--- a/config/webpack/rspackRscPlugin.js
+++ b/config/webpack/rspackRscPlugin.js
@@ -92,8 +92,12 @@ class RspackRscPlugin {
const head = buf.toString('utf-8');
// Allow comments before the directive.
result = /^(?:\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*['"]use client['"]/.test(head);
- } catch (_) {
- // file doesn't exist or can't be read
+ } catch (err) {
+ // ENOENT is expected for virtual/generated modules; anything else is a real problem.
+ if (err.code !== 'ENOENT') {
+ // eslint-disable-next-line no-console
+ console.warn(`[RscPlugin] Failed to read ${filePath}:`, err.message);
+ }
} finally {
if (fd !== undefined) {
try {
From 6429ce0cae03efa3e7fa20b5760eb97447bf477c Mon Sep 17 00:00:00 2001
From: Justin Gordon
Date: Tue, 7 Apr 2026 17:42:19 -1000
Subject: [PATCH 14/16] Add RSC and Node Renderer documentation to CLAUDE.md
and README.md
Document the three-bundle architecture (client, server SSR, RSC), Node
Renderer setup requirements, VM sandbox constraints, RSC component
classification rules, and common troubleshooting patterns. Update README
with current version targets, RSC section, expanded config file list,
and all six Procfile.dev processes.
Motivated by the debugging challenges in PR #723 where undocumented
constraints (VM sandbox lacks require/MessageChannel, 'use client'
classification, Node renderer must run for tests) caused significant
debugging time. Filed shakacode/react_on_rails#3076 with upstream
doc suggestions.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
CLAUDE.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++
README.md | 81 ++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 135 insertions(+), 16 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index fd56bddd1..470848189 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -16,6 +16,76 @@ bundle exec rubocop
bundle exec rubocop -a
```
+## Architecture: Three Bundle System
+
+This project builds **three separate Rspack bundles** via Shakapacker:
+
+| Bundle | Config | Output | Runtime |
+|--------|--------|--------|---------|
+| **Client** | `clientWebpackConfig.js` | `public/packs/` | Browser |
+| **Server (SSR)** | `serverWebpackConfig.js` | `ssr-generated/` | Node renderer VM sandbox |
+| **RSC** | `rscWebpackConfig.js` | RSC output dir | Node renderer (full Node.js) |
+
+### Key constraints
+
+- The **server bundle runs in a VM sandbox** (`vm.createContext()`), NOT full Node.js. It lacks `require`, `MessageChannel`, `TextEncoder`, and other Node/browser globals.
+- Use `resolve.fallback: false` (NOT `externals`) for Node builtins in the server bundle — the VM has no `require()`, so externalized `require('path')` calls will crash.
+- The **RSC bundle** targets Node.js directly with the `react-server` condition, so Node builtins work normally there.
+- A `BannerPlugin` injects a `MessageChannel` polyfill into the server bundle because `react-dom/server.browser` needs it at module load time.
+
+### Build commands
+
+```bash
+# Build only the server bundle
+SERVER_BUNDLE_ONLY=yes bin/shakapacker
+
+# Build only the RSC bundle
+RSC_BUNDLE_ONLY=true bin/shakapacker
+
+# Watch modes (used by Procfile.dev)
+SERVER_BUNDLE_ONLY=yes bin/shakapacker --watch
+RSC_BUNDLE_ONLY=true bin/shakapacker --watch
+```
+
+## Node Renderer
+
+React on Rails Pro's NodeRenderer must be running for SSR and RSC payload generation.
+
+- **Port**: 3800 (configured in `config/initializers/react_on_rails_pro.rb`)
+- **Launcher**: `node react-on-rails-pro-node-renderer.js`
+- **Auth**: Requires `RENDERER_PASSWORD` env var (defaults to `local-dev-renderer-password` in dev/test, required with no fallback in production)
+- **Started automatically** by `bin/dev` / `Procfile.dev`
+
+### Testing requirement
+
+**The Node renderer must be running before `bundle exec rspec`.** Tests will fail with `Net::ReadTimeout` if it is not running. CI starts it as a background process and waits up to 30 seconds for TCP readiness on port 3800.
+
+### Enabled environments
+
+The renderer is enabled when `Rails.env.local?` (development + test) or `REACT_USE_NODE_RENDERER=true`. Production requires explicit env var configuration.
+
+## RSC Component Classification
+
+React on Rails Pro auto-classifies components based on the `'use client'` directive:
+
+- **With `'use client'`** → registered via `ReactOnRails.register()` (traditional SSR/client component)
+- **Without `'use client'`** → registered via `registerServerComponent()` (React Server Component)
+
+### Common gotcha: `.server.jsx` files
+
+In this codebase, `.server.jsx` does NOT mean "React Server Component" — it means traditional **server-side rendering** (e.g., `StaticRouter`). If a `.server.jsx` file uses client APIs like `ReactOnRails.getStore()` or React hooks, it **needs** the `'use client'` directive to prevent RSC misclassification. The naming predates RSC and refers to the Rails SSR render path.
+
+### Key files
+
+| File | Purpose |
+|------|---------|
+| `config/webpack/rscWebpackConfig.js` | RSC bundle configuration |
+| `config/webpack/rspackRscPlugin.js` | Detects `'use client'` directives, emits React Flight manifests |
+| `client/app/packs/rsc-bundle.js` | RSC entry point |
+| `client/app/packs/rsc-client-components.js` | Client component registration for RSC |
+| `config/initializers/react_on_rails_pro.rb` | Node renderer + RSC configuration |
+| `react-on-rails-pro-node-renderer.js` | Node renderer launcher |
+
## Conductor Compatibility (Version Managers)
### Problem
diff --git a/README.md b/README.md
index 395f35569..e10cd0d8e 100644
--- a/README.md
+++ b/README.md
@@ -83,11 +83,10 @@ You can see this tutorial live here: [http://reactrails.com/](http://reactrails.
+ [Configuration Files](#configuration-files)
+ [Additional Resources](#additional-resources)
+ [Thruster HTTP/2 Proxy](#thruster-http2-proxy)
++ [React Server Components (RSC)](#react-server-components-rsc)
+ [Sass, CSS Modules, and Tailwind CSS integration](#sass-css-modules-and-tailwind-css-integration)
+ [Fonts with SASS](#fonts-with-sass)
+ [Process Management during Development](#process-management-during-development)
-+ [Rendering with Express Server](#rendering-with-express-server)
- + [Setup](#setup)
+ [Contributors](#contributors)
+ [About ShakaCode](#about-shakacode)
+ [RubyMine and WebStorm](#rubymine-and-webstorm)
@@ -95,7 +94,8 @@ You can see this tutorial live here: [http://reactrails.com/](http://reactrails.
## Demoed Functionality
-- Example of using the [react_on_rails gem](https://github.com/shakacode/react_on_rails) for easy React + Rspack integration with Rails.
+- Example of using [React on Rails Pro](https://www.shakacode.com/react-on-rails-pro/) with the NodeRenderer for server-side rendering.
+- Example of [React Server Components (RSC)](#react-server-components-rsc) with streaming and selective hydration.
- Example of React with [CSS Modules](http://glenmaddern.com/articles/css-modules) inside Rails using modern Shakapacker/Rspack builds.
- Example of enabling hot reloading of both JS and CSS (modules) from your Rails app in development mode. Change your code. Save. Browser updates without a refresh!
- Example of React/Redux with Rails Action Cable.
@@ -110,18 +110,17 @@ You can see this tutorial live here: [http://reactrails.com/](http://reactrails.
See package.json and Gemfile for versions
-1. [react_on_rails gem](https://github.com/shakacode/react_on_rails/)
-1. [React](http://facebook.github.io/react/)
+1. [React on Rails Pro](https://www.shakacode.com/react-on-rails-pro/) with NodeRenderer for SSR and React Server Components
+1. [React 19](http://facebook.github.io/react/) with React Server Components support
1. [Redux](https://github.com/reactjs/redux)
1. [react-router](https://github.com/reactjs/react-router)
1. [react-router-redux](https://github.com/reactjs/react-router-redux)
1. [Rspack with hot-reload](https://rspack.dev/guide/features/dev-server) (for local dev)
-1. [Babel transpiler](https://github.com/babel/babel)
+1. [SWC transpiler](https://swc.rs/) for fast JavaScript/TypeScript compilation
1. [Ruby on Rails 8](http://rubyonrails.org/) for backend app and comparison with plain HTML
1. [Thruster](https://github.com/basecamp/thruster) - Zero-config HTTP/2 proxy for optimized asset delivery
1. [Heroku deployment guide](https://devcenter.heroku.com/articles/getting-started-with-rails8)
1. [Deployment to the ControlPlane](.controlplane/readme.md)
-1. [Turbolinks 5](https://github.com/turbolinks/turbolinks)
1. [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss)
## Basic Demo Setup
@@ -183,10 +182,10 @@ assets_bundler: rspack
### Version Targets
-- `react_on_rails` gem: `16.4.0`
-- `react-on-rails` npm package: `16.4.0`
-- `shakapacker` gem/npm package: `9.7.0`
-- `@rspack/core` and `@rspack/cli`: `2.0.0-beta.7` (latest published v2 prerelease at the time of this update)
+- `react_on_rails_pro` gem: `16.6.0.rc.0`
+- `react-on-rails-pro` npm package: `16.6.0-rc.0`
+- `shakapacker` gem/npm package: `10.0.0-rc.0`
+- `@rspack/core` and `@rspack/cli`: `2.0.0-beta.7`
### Why Rspack
@@ -197,10 +196,13 @@ assets_bundler: rspack
### Configuration Files
All bundler configuration is in `config/webpack/`:
-- `webpackConfig.js` - Main Shakapacker entry point
+- `webpackConfig.js` - Main Shakapacker entry point (routes to client, server, or RSC config)
- `commonWebpackConfig.js` - Shared configuration
-- `clientWebpackConfig.js` - Client bundle settings
-- `serverWebpackConfig.js` - Server-side rendering bundle
+- `clientWebpackConfig.js` - Client bundle settings (browser, with HMR)
+- `serverWebpackConfig.js` - Server-side rendering bundle (runs in Node renderer VM sandbox)
+- `rscWebpackConfig.js` - React Server Components bundle (runs in Node renderer, full Node.js)
+- `rspackRscPlugin.js` - Custom plugin that detects `'use client'` directives and emits React Flight manifests
+- `bundlerUtils.js` - Shared bundler detection utilities
- `development.js`, `production.js`, `test.js` - Environment-specific settings
### Additional Resources
@@ -243,6 +245,47 @@ The server automatically benefits from HTTP/2, caching, and compression without
For detailed information, troubleshooting, and advanced configuration options, see [docs/thruster.md](docs/thruster.md).
+## React Server Components (RSC)
+
+This project demonstrates React Server Components with React on Rails Pro. Visit `/server-components` to see the demo page.
+
+### How It Works
+
+The app builds **three separate bundles**:
+
+1. **Client bundle** — Browser JavaScript with HMR, built by `clientWebpackConfig.js`
+2. **Server bundle** — Traditional SSR, runs in the Node renderer's VM sandbox, built by `serverWebpackConfig.js`
+3. **RSC bundle** — React Server Components with the `react-server` condition, runs in full Node.js, built by `rscWebpackConfig.js`
+
+The [React on Rails Pro NodeRenderer](https://www.shakacode.com/react-on-rails-pro/) runs on port 3800 and handles both SSR rendering and RSC payload generation. A custom `RspackRscPlugin` detects `'use client'` directives in source files and emits the React Flight manifests (`react-client-manifest.json`, `react-server-client-manifest.json`) that React uses to resolve client component references during streaming.
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `config/webpack/rscWebpackConfig.js` | RSC bundle configuration |
+| `config/webpack/rspackRscPlugin.js` | `'use client'` detection and manifest generation |
+| `client/app/packs/rsc-bundle.js` | RSC entry point — registers server components |
+| `client/app/packs/rsc-client-components.js` | Client component registration for RSC hydration |
+| `config/initializers/react_on_rails_pro.rb` | NodeRenderer and RSC configuration |
+| `react-on-rails-pro-node-renderer.js` | Node renderer launcher (port 3800, 3 workers) |
+| `client/app/bundles/server-components/` | RSC demo components |
+
+### Troubleshooting
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| `Net::ReadTimeout` in tests | Node renderer not running | Start it with `bin/dev` or `node react-on-rails-pro-node-renderer.js` |
+| Component renders blank/empty div | Missing `'use client'` directive | Add `'use client'` to components using client APIs (hooks, stores, event handlers) |
+| `MessageChannel is not defined` | Server bundle VM missing polyfill | Check `BannerPlugin` in `serverWebpackConfig.js` |
+| `require is not defined` | Server bundle using `externals` | Use `resolve.fallback: false` instead — the VM sandbox has no `require()` |
+| RSC manifests missing | RSC bundle not built | Run `RSC_BUNDLE_ONLY=true bin/shakapacker` |
+
+### Further Reading
+
+- [React on Rails Pro RSC Documentation](https://www.shakacode.com/react-on-rails-pro/)
+- [React Server Components RFC](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md)
+
## Sass, CSS Modules, and Tailwind CSS Integration
This example project uses mainly Tailwind CSS for styling.
Besides this, it also demonstrates Sass and CSS modules, particularly for some CSS transitions.
@@ -270,12 +313,18 @@ export default class CommentBox extends React.Component {
### Fonts with SASS
The tutorial makes use of a custom font OpenSans-Light. We're doing this to show how to add assets for the CSS processing. The font files are located under [client/app/assets/fonts](client/app/assets/fonts) and are loaded by both the Rails asset pipeline and the Rspack HMR server.
-## Process management during development
+## Process Management during Development
```bash
bundle exec foreman start -f
```
-1. [`Procfile.dev`](Procfile.dev): Starts the Rspack Dev Server and Rails with Hot Reloading.
+1. [`Procfile.dev`](Procfile.dev): Starts all development processes with Hot Reloading:
+ - `rescript` — ReScript watch mode
+ - `rails` — Rails server via Thruster on port 3000
+ - `wp-client` — Client Rspack dev server with HMR
+ - `wp-server` — Server Rspack watcher for SSR bundle
+ - `wp-rsc` — RSC Rspack watcher for React Server Components bundle
+ - `node-renderer` — React on Rails Pro Node renderer on port 3800
1. [`Procfile.dev-static`](Procfile.dev-static): Starts the Rails server and generates static assets that are used for tests.
## Contributors
From f09b789d3dc8a28fcdda4c66cf6002218c99b19f Mon Sep 17 00:00:00 2001
From: Justin Gordon
Date: Tue, 7 Apr 2026 18:09:24 -1000
Subject: [PATCH 15/16] Add RSC bundle initialization, fix dev dependency, and
add QA playbook
- Import react-on-rails-pro in rsc-bundle.js to initialize RSC support
(resolves to ReactOnRailsRSC.js with react-server condition)
- Move @rspack/dev-server to devDependencies where it belongs
- Add QA_PLAYBOOK.md with manual testing checklist for all rendering modes
Co-Authored-By: Claude Opus 4.6 (1M context)
---
QA_PLAYBOOK.md | 149 +++++++++++++++++++++++++++++++++
client/app/packs/rsc-bundle.js | 5 ++
package.json | 1 +
yarn.lock | 145 ++++++++++++++++++++++++++------
4 files changed, 274 insertions(+), 26 deletions(-)
create mode 100644 QA_PLAYBOOK.md
diff --git a/QA_PLAYBOOK.md b/QA_PLAYBOOK.md
new file mode 100644
index 000000000..abd109092
--- /dev/null
+++ b/QA_PLAYBOOK.md
@@ -0,0 +1,149 @@
+# Manual QA Playbook
+
+Run this checklist before merging PRs to catch regressions across rendering modes.
+
+## Prerequisites
+
+```bash
+bin/dev # Start all processes (Rails, webpack client/server/RSC, Node renderer, ReScript)
+```
+
+Verify all 6 processes start without crashes in the terminal output:
+- `rails` - Rails server via Thruster on port 3000
+- `wp-client` - Rspack dev server (HMR)
+- `wp-server` - SSR bundle watcher
+- `wp-rsc` - RSC bundle watcher
+- `node-renderer` - Node.js renderer on port 3800
+- `rescript` - ReScript compiler watch mode
+
+---
+
+## 1. Homepage `/` (SSR + Redux + React Router)
+
+**Rendering:** Server-side rendered with Redux store hydration.
+
+- [ ] Page loads with comments visible (not a blank flash before JS loads)
+- [ ] View page source shows pre-rendered HTML content (SSR working)
+- [ ] No console errors
+- [ ] Navigation bar renders with correct links
+- [ ] Comment list displays existing comments
+
+**Comment CRUD:**
+- [ ] Submit a comment via the form (fill name + text, click Post)
+- [ ] New comment appears immediately in the list
+- [ ] Submit with empty fields shows validation errors
+- [ ] Switch between Inline/Horizontal/Stacked form layouts
+
+**React Router:**
+- [ ] Click "React Router Demo" link - navigates without full page reload
+- [ ] Visit `/react-router` directly - shows React Router content
+- [ ] Visit `/react-router/redirect` - redirects back to `/`
+
+**ActionCable (real-time):**
+- [ ] Open page in two tabs
+- [ ] Submit a comment in tab 1
+- [ ] Comment appears in tab 2 without refresh
+
+---
+
+## 2. Simple React `/simple` (CSR only)
+
+**Rendering:** Client-side only, no SSR.
+
+- [ ] Page loads (content appears after JS loads)
+- [ ] View page source shows empty mount point (no pre-rendered HTML)
+- [ ] No console errors
+- [ ] Comment form works (submit, validation)
+
+---
+
+## 3. No Router `/no-router` (SSR + Redux, no React Router)
+
+- [ ] Page loads with pre-rendered comments
+- [ ] Comment CRUD works
+- [ ] Form layout switching works
+- [ ] No console errors
+
+---
+
+## 4. ReScript `/rescript` (SSR)
+
+- [ ] Page loads with pre-rendered ReScript component
+- [ ] No console errors
+- [ ] Content displays correctly
+
+---
+
+## 5. Stimulus `/stimulus` (Rails + Turbo + Stimulus)
+
+**No React on this page - pure Rails.**
+
+- [ ] Page loads with comment form
+- [ ] Tab switching works (Horizontal/Inline/Stacked forms via Turbo)
+- [ ] Submit comment - appears in list via Turbo (no full page reload)
+- [ ] Validation errors display on empty submit
+- [ ] "Force Refresh" reloads comment list via Turbo
+- [ ] No console errors
+
+---
+
+## 6. Classic Rails `/comments` (scaffold)
+
+- [ ] List page shows all comments
+- [ ] Create: `/comments/new` - fill form, save, redirects to show page
+- [ ] Edit: click Edit on any comment, change fields, save
+- [ ] Delete: click Destroy, confirm, comment removed
+- [ ] Validation: submit empty form shows errors
+
+---
+
+## 7. Server Components `/server-components` (RSC)
+
+**Rendering:** React Server Components via Node renderer streaming.
+
+- [ ] Page loads without errors
+- [ ] Server-only content renders (e.g., ServerInfo with Node.js `os` data)
+- [ ] Client components hydrate and are interactive (e.g., TogglePanel)
+- [ ] No console errors
+- [ ] `/rsc_payload/ServerComponentsPage` returns RSC Flight payload (not HTML error page)
+
+---
+
+## Cross-cutting checks
+
+### Console errors
+- [ ] No red errors on any page
+- [ ] No hydration mismatch warnings
+
+### SSR verification
+For SSR pages (`/`, `/no-router`, `/rescript`):
+- [ ] View Source shows pre-rendered HTML inside React mount points
+
+### Network
+- [ ] No 404s for assets in Network tab
+- [ ] WebSocket `/cable` connection established (for ActionCable pages)
+- [ ] All JS/CSS bundles load successfully
+
+### Responsive
+- [ ] Pages render correctly at mobile width (375px)
+- [ ] Navigation collapses/adapts on small screens
+
+---
+
+## RSpec smoke test
+
+```bash
+bundle exec rspec spec/system/ spec/requests/
+```
+
+All system and request specs should pass.
+
+---
+
+## Quick regression check (minimal)
+
+If time is limited, test these 3 paths that cover all rendering modes:
+
+1. **`/`** - SSR + Redux + React Router (submit a comment)
+2. **`/stimulus`** - Stimulus + Turbo (submit a comment)
+3. **`/server-components`** - RSC (page loads without errors)
diff --git a/client/app/packs/rsc-bundle.js b/client/app/packs/rsc-bundle.js
index b6fcae1bb..caab73b45 100644
--- a/client/app/packs/rsc-bundle.js
+++ b/client/app/packs/rsc-bundle.js
@@ -3,6 +3,11 @@
// It imports the same client component registrations as server-bundle.js,
// plus the server component registrations.
+// Initialize ReactOnRails RSC support. With the 'react-server' resolve condition,
+// this resolves to ReactOnRailsRSC.js which sets isRSCBundle = true and registers
+// serverRenderRSCReactComponent. Must be imported before other registrations.
+import 'react-on-rails-pro';
+
// Import stores registration (has 'use client' — RSC loader replaces with client reference)
import './stores-registration';
diff --git a/package.json b/package.json
index a5dbe00c3..d1db8de5b 100644
--- a/package.json
+++ b/package.json
@@ -114,6 +114,7 @@
"devDependencies": {
"@babel/eslint-parser": "^7.16.5",
"@babel/preset-react": "^7.18.6",
+ "@rspack/dev-server": "2.0.0-beta.7",
"@rspack/plugin-react-refresh": "1.6.1",
"@tailwindcss/typography": "^0.5.10",
"@testing-library/dom": "^10.4.1",
diff --git a/yarn.lock b/yarn.lock
index f6979b086..69fedc573 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2159,6 +2159,26 @@
dependencies:
"@rspack/binding" "2.0.0-beta.7"
+"@rspack/dev-middleware@2.0.0-beta.2":
+ version "2.0.0-beta.2"
+ resolved "https://registry.npmjs.org/@rspack/dev-middleware/-/dev-middleware-2.0.0-beta.2.tgz#a5bb784c9f85e6dff893950306df54a124fb0eeb"
+ integrity sha512-cbCcloAUYrb65LPd0HLhMKOWKasEM1rGXb4P9aonQcoODC9ThTdzUuZhXEwlGrinBDcMmOzEk9TYfTg9PDUevw==
+
+"@rspack/dev-server@2.0.0-beta.7":
+ version "2.0.0-beta.7"
+ resolved "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-2.0.0-beta.7.tgz#97ebc8f35c2d1b5a77c808d652397182b06791df"
+ integrity sha512-/keLmG2PITbCCeUqDspj2DtDyxOXqlhKG8IiG7FKUky9jgiiRWu6F3S1t7hD72XeSnrxYEyFdeJ7uVuMQ73j0A==
+ dependencies:
+ "@rspack/dev-middleware" "2.0.0-beta.2"
+ "@types/ws" "^8.18.1"
+ chokidar "^5.0.0"
+ connect-history-api-fallback "^2.0.0"
+ connect-next "^4.0.0"
+ http-proxy-middleware "^3.0.5"
+ ipaddr.js "^2.3.0"
+ serve-static "^2.2.1"
+ ws "^8.19.0"
+
"@rspack/lite-tapable@^1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.0.1.tgz#d4540a5d28bd6177164bc0ba0bee4bdec0458591"
@@ -2501,7 +2521,7 @@
resolved "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz#5b749ab2b16ba113423feb1a64a95dcd30398472"
integrity sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==
-"@types/http-proxy@^1.17.8":
+"@types/http-proxy@^1.17.15", "@types/http-proxy@^1.17.8":
version "1.17.17"
resolved "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz#d9e2c4571fe3507343cb210cd41790375e59a533"
integrity sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==
@@ -2664,7 +2684,7 @@
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz"
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
-"@types/ws@^8.5.5":
+"@types/ws@^8.18.1", "@types/ws@^8.5.5":
version "8.18.1"
resolved "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
@@ -3679,6 +3699,13 @@ chokidar@^4.0.0:
dependencies:
readdirp "^4.0.1"
+chokidar@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz#949c126a9238a80792be9a0265934f098af369a5"
+ integrity sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==
+ dependencies:
+ readdirp "^5.0.0"
+
chrome-trace-event@^1.0.2:
version "1.0.4"
resolved "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b"
@@ -3860,6 +3887,11 @@ connect-history-api-fallback@^2.0.0:
resolved "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8"
integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==
+connect-next@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.npmjs.org/connect-next/-/connect-next-4.0.1.tgz#4063d92c11cdc58bca371c074ff24539468ae3a4"
+ integrity sha512-nkHJWto78sXAooScrgvRt9E9omtwHBUTjCWRgxe3NbwNVC9cW4Lu0il0m2O867pgzJV66YXi2tXtSPtZy3Zmeg==
+
content-disposition@0.5.4, content-disposition@~0.5.4:
version "0.5.4"
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
@@ -4197,7 +4229,7 @@ debug@2.6.9:
dependencies:
ms "2.0.0"
-debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1:
+debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.6, debug@^4.4.1, debug@^4.4.3:
version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -4503,16 +4535,16 @@ emojis-list@^3.0.0:
resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz"
integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
+encodeurl@^2.0.0, encodeurl@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
+ integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
+
encodeurl@~1.0.2:
version "1.0.2"
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
-encodeurl@~2.0.0:
- version "2.0.0"
- resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz"
- integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
-
enhanced-resolve@^0.9.1:
version "0.9.1"
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz"
@@ -4704,7 +4736,7 @@ escalade@^3.1.1, escalade@^3.2.0:
resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
-escape-html@~1.0.3:
+escape-html@^1.0.3, escape-html@~1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
@@ -4997,7 +5029,7 @@ esutils@^2.0.2:
resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
-etag@~1.8.1:
+etag@^1.8.1, etag@~1.8.1:
version "1.8.1"
resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
@@ -5392,6 +5424,11 @@ fresh@0.5.2, fresh@~0.5.2:
resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+fresh@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4"
+ integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==
+
fs-extra@^10.0.0:
version "10.1.0"
resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf"
@@ -5767,6 +5804,17 @@ http-errors@2.0.0:
statuses "2.0.1"
toidentifier "1.0.1"
+http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
+ integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
+ dependencies:
+ depd "~2.0.0"
+ inherits "~2.0.4"
+ setprototypeof "~1.2.0"
+ statuses "~2.0.2"
+ toidentifier "~1.0.1"
+
http-errors@~1.8.0:
version "1.8.1"
resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c"
@@ -5778,17 +5826,6 @@ http-errors@~1.8.0:
statuses ">= 1.5.0 < 2"
toidentifier "1.0.1"
-http-errors@~2.0.0, http-errors@~2.0.1:
- version "2.0.1"
- resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
- integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
- dependencies:
- depd "~2.0.0"
- inherits "~2.0.4"
- setprototypeof "~1.2.0"
- statuses "~2.0.2"
- toidentifier "~1.0.1"
-
http-parser-js@>=0.5.1:
version "0.5.10"
resolved "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075"
@@ -5813,6 +5850,18 @@ http-proxy-middleware@^2.0.3:
is-plain-obj "^3.0.0"
micromatch "^4.0.2"
+http-proxy-middleware@^3.0.5:
+ version "3.0.5"
+ resolved "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz#9dcde663edc44079bc5a9c63e03fe5e5d6037fab"
+ integrity sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==
+ dependencies:
+ "@types/http-proxy" "^1.17.15"
+ debug "^4.3.6"
+ http-proxy "^1.18.1"
+ is-glob "^4.0.3"
+ is-plain-object "^5.0.0"
+ micromatch "^4.0.8"
+
http-proxy@^1.18.1:
version "1.18.1"
resolved "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
@@ -5967,7 +6016,7 @@ ipaddr.js@1.9.1:
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
-ipaddr.js@^2.0.1, ipaddr.js@^2.1.0:
+ipaddr.js@^2.0.1, ipaddr.js@^2.1.0, ipaddr.js@^2.3.0:
version "2.3.0"
resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz#71dce70e1398122208996d1c22f2ba46a24b1abc"
integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==
@@ -7360,7 +7409,7 @@ mime-db@1.52.0:
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
-"mime-db@>= 1.43.0 < 2":
+"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0:
version "1.54.0"
resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
@@ -7372,6 +7421,13 @@ mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.24, mime-types@~2.1.34,
dependencies:
mime-db "1.52.0"
+mime-types@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab"
+ integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==
+ dependencies:
+ mime-db "^1.54.0"
+
mime@1.6.0:
version "1.6.0"
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
@@ -7641,7 +7697,7 @@ on-exit-leak-free@^2.1.0:
resolved "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8"
integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==
-on-finished@2.4.1, on-finished@~2.4.1:
+on-finished@2.4.1, on-finished@^2.4.1, on-finished@~2.4.1:
version "2.4.1"
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
@@ -7795,7 +7851,7 @@ parse5@^7.0.0, parse5@^7.2.1:
dependencies:
entities "^6.0.0"
-parseurl@~1.3.3:
+parseurl@^1.3.3, parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
@@ -8905,6 +8961,11 @@ readdirp@^4.0.1:
resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
+readdirp@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz#fbf1f71a727891d685bb1786f9ba74084f6e2f91"
+ integrity sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==
+
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
@@ -9391,6 +9452,23 @@ send@0.19.0:
range-parser "~1.2.1"
statuses "2.0.1"
+send@^1.2.0:
+ version "1.2.1"
+ resolved "https://registry.npmjs.org/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed"
+ integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==
+ dependencies:
+ debug "^4.4.3"
+ encodeurl "^2.0.0"
+ escape-html "^1.0.3"
+ etag "^1.8.1"
+ fresh "^2.0.0"
+ http-errors "^2.0.1"
+ mime-types "^3.0.2"
+ ms "^2.1.3"
+ on-finished "^2.4.1"
+ range-parser "^1.2.1"
+ statuses "^2.0.2"
+
send@~0.19.0, send@~0.19.1:
version "0.19.2"
resolved "https://registry.npmjs.org/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29"
@@ -9440,6 +9518,16 @@ serve-static@1.16.2:
parseurl "~1.3.3"
send "0.19.0"
+serve-static@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9"
+ integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==
+ dependencies:
+ encodeurl "^2.0.0"
+ escape-html "^1.0.3"
+ parseurl "^1.3.3"
+ send "^1.2.0"
+
serve-static@~1.16.2:
version "1.16.3"
resolved "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9"
@@ -9713,7 +9801,7 @@ statuses@2.0.1:
resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
-statuses@~2.0.1, statuses@~2.0.2:
+statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
@@ -10773,6 +10861,11 @@ ws@^8.18.0:
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
+ws@^8.19.0:
+ version "8.20.0"
+ resolved "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8"
+ integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==
+
xml-name-validator@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
From 6ecfa97ec64b123fefa5434f5201a93c39f27a04 Mon Sep 17 00:00:00 2001
From: Justin Gordon
Date: Wed, 8 Apr 2026 22:30:59 -1000
Subject: [PATCH 16/16] Fix RSC manifest and renderer review items
---
config/webpack/rscWebpackConfig.js | 4 +-
config/webpack/rspackRscPlugin.js | 86 ++++++++++++++++++---------
config/webpack/serverWebpackConfig.js | 6 +-
react-on-rails-pro-node-renderer.js | 23 ++++++-
4 files changed, 84 insertions(+), 35 deletions(-)
diff --git a/config/webpack/rscWebpackConfig.js b/config/webpack/rscWebpackConfig.js
index a838a01e5..b4b83e1aa 100644
--- a/config/webpack/rscWebpackConfig.js
+++ b/config/webpack/rscWebpackConfig.js
@@ -4,8 +4,8 @@
// The RSC bundle runs server components in the Node renderer and produces
// the Flight payload that React uses to hydrate on the client.
//
-// Unlike the server bundle (which uses ExecJS), the RSC bundle targets Node.js
-// and can use Node.js built-in modules like os, fs, path, etc.
+// Unlike the server bundle (which runs in the Node renderer VM sandbox), the
+// RSC bundle targets Node.js and can use built-in modules like os, fs, path, etc.
const path = require('path');
const { config } = require('shakapacker');
diff --git a/config/webpack/rspackRscPlugin.js b/config/webpack/rspackRscPlugin.js
index b78992492..769bb53d6 100644
--- a/config/webpack/rspackRscPlugin.js
+++ b/config/webpack/rspackRscPlugin.js
@@ -7,6 +7,8 @@
// that the React flight protocol needs to resolve client component references.
const fs = require('fs');
+const path = require('path');
+const { pathToFileURL } = require('url');
const { sources } = require('@rspack/core');
class RspackRscPlugin {
@@ -18,7 +20,6 @@ class RspackRscPlugin {
this.clientManifestFilename = options.isServer
? 'react-server-client-manifest.json'
: 'react-client-manifest.json';
- this.ssrManifestFilename = 'react-ssr-manifest.json';
this.useClientCache = new Map();
}
@@ -35,7 +36,15 @@ class RspackRscPlugin {
stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT || 5000,
},
() => {
- const manifest = {};
+ const filePathToModuleMetadata = {};
+ const configuredCrossOriginLoading = this._getCrossOriginLoading(compilation);
+ const manifest = {
+ moduleLoading: {
+ prefix: compilation.outputOptions.publicPath || '',
+ crossOrigin: configuredCrossOriginLoading,
+ },
+ filePathToModuleMetadata,
+ };
for (const chunk of compilation.chunks) {
const chunkFiles = [];
@@ -51,11 +60,11 @@ class RspackRscPlugin {
: [];
for (const mod of modules) {
- this._processModule(mod, chunk, chunkFiles, manifest, compilation);
+ this._processModule(mod, chunk, chunkFiles, filePathToModuleMetadata, compilation);
// Handle concatenated modules
if (mod.modules) {
for (const innerMod of mod.modules) {
- this._processModule(innerMod, chunk, chunkFiles, manifest, compilation);
+ this._processModule(innerMod, chunk, chunkFiles, filePathToModuleMetadata, compilation);
}
}
}
@@ -65,19 +74,21 @@ class RspackRscPlugin {
this.clientManifestFilename,
new sources.RawSource(JSON.stringify(manifest, null, 2)),
);
-
- // Emit SSR manifest (maps module IDs to SSR module data)
- if (!this.isServer) {
- compilation.emitAsset(
- this.ssrManifestFilename,
- new sources.RawSource(JSON.stringify({}, null, 2)),
- );
- }
},
);
});
}
+ _getCrossOriginLoading(compilation) {
+ const configuredCrossOriginLoading = compilation.outputOptions.crossOriginLoading;
+
+ if (typeof configuredCrossOriginLoading !== 'string') {
+ return null;
+ }
+
+ return configuredCrossOriginLoading === 'use-credentials' ? configuredCrossOriginLoading : 'anonymous';
+ }
+
_hasUseClientDirective(filePath) {
if (this.useClientCache.has(filePath)) return this.useClientCache.get(filePath);
@@ -112,11 +123,12 @@ class RspackRscPlugin {
return result;
}
- _processModule(mod, chunk, chunkFiles, manifest, compilation) {
+ _processModule(mod, chunk, chunkFiles, filePathToModuleMetadata, compilation) {
const resource = mod.resource || mod.userRequest;
if (!resource || !resource.match(/\.(js|jsx|ts|tsx)$/)) return;
// Skip node_modules
if (resource.includes('node_modules')) return;
+ if (!this._shouldIncludeInManifest(resource)) return;
// Check original file for 'use client' directive
if (!this._hasUseClientDirective(resource)) return;
@@ -130,27 +142,43 @@ class RspackRscPlugin {
chunks.push(chunk.id, file);
}
- // Build the module entry with all exported names
- const ssrEntry = {
+ const key = pathToFileURL(resource).href;
+ const existingEntry = filePathToModuleMetadata[key];
+
+ if (existingEntry) {
+ const knownChunkIds = new Set();
+ for (let index = 0; index < existingEntry.chunks.length; index += 2) {
+ knownChunkIds.add(existingEntry.chunks[index]);
+ }
+
+ for (let index = 0; index < chunks.length; index += 2) {
+ if (!knownChunkIds.has(chunks[index])) {
+ existingEntry.chunks.push(chunks[index], chunks[index + 1]);
+ knownChunkIds.add(chunks[index]);
+ }
+ }
+ return;
+ }
+
+ filePathToModuleMetadata[key] = {
id: moduleId,
chunks: chunks,
name: '*',
- async: false,
};
+ }
- // Use resource path as the key (React flight protocol convention)
- const key = resource;
- if (!manifest[key]) {
- manifest[key] = {};
- }
- manifest[key]['*'] = ssrEntry;
- manifest[key][''] = ssrEntry;
-
- // Also add default export entry
- manifest[key]['default'] = {
- ...ssrEntry,
- name: 'default',
- };
+ _shouldIncludeInManifest(resource) {
+ const normalizedResource = path.normalize(resource);
+ const serverComponentsRoot = path.normalize('client/app/bundles/server-components/');
+ const storesRegistrationPath = path.normalize('client/app/packs/stores-registration.js');
+
+ // The RSC runtime only needs client references that can appear inside the
+ // server-components demo tree. Pulling in unrelated app bundles causes the
+ // client and server manifests to diverge and breaks buildClientRenderer().
+ return (
+ normalizedResource.includes(serverComponentsRoot) ||
+ normalizedResource.endsWith(storesRegistrationPath)
+ );
}
}
diff --git a/config/webpack/serverWebpackConfig.js b/config/webpack/serverWebpackConfig.js
index dd20c06d8..3f0b2becc 100644
--- a/config/webpack/serverWebpackConfig.js
+++ b/config/webpack/serverWebpackConfig.js
@@ -176,16 +176,18 @@ const configureServer = () => {
// The Node renderer runs bundles in a VM sandbox that lacks browser globals
// like MessageChannel and TextEncoder. Inject polyfills at the top of the
- // bundle so react-dom/server.browser can initialize.
+ // bundle so react-dom/server.browser can initialize with the async task
+ // boundary its scheduler expects.
serverWebpackConfig.plugins.push(
new bundler.BannerPlugin({
banner: [
'if(typeof MessageChannel==="undefined"){',
+ ' var enqueueTask=typeof setImmediate==="function"?setImmediate:function(cb){setTimeout(cb,0);};',
' globalThis.MessageChannel=class MessageChannel{',
' constructor(){',
' this.port1={onmessage:null};',
' this.port2={postMessage:function(msg){',
- ' var p=this._port1;if(p.onmessage)p.onmessage({data:msg});',
+ ' var p=this._port1;enqueueTask(function(){if(p.onmessage)p.onmessage({data:msg});});',
' }};',
' this.port2._port1=this.port1;',
' }',
diff --git a/react-on-rails-pro-node-renderer.js b/react-on-rails-pro-node-renderer.js
index d80045899..0eed6c149 100644
--- a/react-on-rails-pro-node-renderer.js
+++ b/react-on-rails-pro-node-renderer.js
@@ -8,13 +8,32 @@ if (!rendererPassword) {
throw new Error('RENDERER_PASSWORD must be set in production');
}
+function parseIntegerEnv(name, defaultValue, { min, max = Number.MAX_SAFE_INTEGER }) {
+ const rawValue = process.env[name];
+ if (rawValue == null || rawValue.trim() === '') {
+ return defaultValue;
+ }
+
+ const normalized = rawValue.trim();
+ if (!/^\d+$/.test(normalized)) {
+ throw new Error(`Invalid ${name}: "${rawValue}". Expected an integer.`);
+ }
+
+ const parsed = Number.parseInt(normalized, 10);
+ if (parsed < min || parsed > max) {
+ throw new Error(`Invalid ${name}: "${rawValue}". Expected a value between ${min} and ${max}.`);
+ }
+
+ return parsed;
+}
+
const config = {
serverBundleCachePath: path.resolve(__dirname, '.node-renderer-bundles'),
logLevel: process.env.RENDERER_LOG_LEVEL || 'debug',
password: rendererPassword,
- port: process.env.RENDERER_PORT || 3800,
+ port: parseIntegerEnv('RENDERER_PORT', 3800, { min: 1, max: 65535 }),
supportModules: true,
- workersCount: Number(process.env.NODE_RENDERER_CONCURRENCY || 3),
+ workersCount: parseIntegerEnv('NODE_RENDERER_CONCURRENCY', 3, { min: 0 }),
};
if (process.env.CI) {