diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdf7d7a57..c7e320593 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,18 @@ You can find the **package** version numbers from this repo's tags and below in
## [Unreleased]
*Add changes in master not yet tagged.*
+## [4.0.0-rc.14] - 2025-06-22
+
+### Improved
+- Improved RSC rendering flow by eliminating double rendering of server components and reducing the number of HTTP requests.
+- Updated communication protocol between Node Renderer and Rails to version 2.0.0 which supports the ability to upload multiple bundles at once.
+- Added the ability to communicate between different bundles on the renderer by using the `runOnOtherBundle` function which is globally available for the rendering request.
+
+[PR 515](https://github.com/shakacode/react_on_rails_pro/pull/515) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
+
+
+## [4.0.0-rc.13] - 2025-03-07
+
### Added
- 🚀 **Introducing React Server Components Support!** 🎉
- Experience the future of React with full RSC integration
diff --git a/Gemfile.development_dependencies b/Gemfile.development_dependencies
index 2f9d386cd..a4140f5cd 100644
--- a/Gemfile.development_dependencies
+++ b/Gemfile.development_dependencies
@@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.3.7'
-gem "react_on_rails", "15.0.0.alpha.2" # keep in sync with package.json files
+gem "react_on_rails", "15.0.0.rc.1" # keep in sync with package.json files
# For local development
# Add the following line to the Gemfile.local file
diff --git a/Gemfile.lock b/Gemfile.lock
index b01d546de..30d27f0ce 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -284,7 +284,7 @@ GEM
ffi (~> 1.0)
rdoc (6.12.0)
psych (>= 4.0.0)
- react_on_rails (15.0.0.alpha.2)
+ react_on_rails (15.0.0.rc.1)
addressable
connection_pool
execjs (~> 2.5)
@@ -462,7 +462,7 @@ DEPENDENCIES
pry-theme
puma (~> 6)
rails (~> 7.1)
- react_on_rails (= 15.0.0.alpha.2)
+ react_on_rails (= 15.0.0.rc.1)
react_on_rails_pro!
rspec-rails
rspec-retry
diff --git a/babel.config.js b/babel.config.js
index e6ffbd417..89c9b74d6 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1,3 +1,4 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
+ plugins: ['@babel/plugin-syntax-import-attributes'],
};
diff --git a/docs/node-renderer/js-configuration.md b/docs/node-renderer/js-configuration.md
index 11460a9cb..ce9641768 100644
--- a/docs/node-renderer/js-configuration.md
+++ b/docs/node-renderer/js-configuration.md
@@ -17,7 +17,7 @@ Here are the options available for the JavaScript renderer configuration object,
1. **logHttpLevel** (default: `process.env.RENDERER_LOG_HTTP_LEVEL || 'error'`) - The HTTP server log level (same allowed values as `logLevel`).
1. **fastifyServerOptions** (default: `{}`) - Additional options to pass to the Fastify server factory. See [Fastify documentation](https://fastify.dev/docs/latest/Reference/Server/#factory).
1. **bundlePath** (default: `process.env.RENDERER_BUNDLE_PATH || '/tmp/react-on-rails-pro-node-renderer-bundles'` ) - path to a temp directory where uploaded bundle files will be stored. For example you can set it to `path.resolve(__dirname, './.node-renderer-bundles')` if you configured renderer from the `/` directory of your app.
-1. **workersCount** (default: `env.RENDERER_WORKERS_COUNT || defaultWorkersCount()` where default is your CPUs count - 1) - Number of workers that will be forked to serve rendering requests. If you set this manually make sure that value is a **Number** and is `>= 1`.
+1. **workersCount** (default: `process.env.RENDERER_WORKERS_COUNT || defaultWorkersCount()` where default is your CPUs count - 1) - Number of workers that will be forked to serve rendering requests. If you set this manually make sure that value is a **Number** and is `>= 0`. Setting this to `0` will run the renderer in a single process mode without forking any workers, which is useful for debugging purposes. For production use, the value should be `>= 1`.
1. **password** (default: `env.RENDERER_PASSWORD`) - The password expected to receive from the **Rails client** to authenticate rendering requests.
If no password is set, no authentication will be required.
1. **allWorkersRestartInterval** (default: `env.RENDERER_ALL_WORKERS_RESTART_INTERVAL`) - Interval in minutes between scheduled restarts of all workers. By default restarts are not enabled. If restarts are enabled, `delayBetweenIndividualWorkerRestarts` should also be set.
@@ -63,10 +63,14 @@ const config = {
// All other values are the defaults, as described above
};
+// For debugging, run in single process mode
+if (process.env.NODE_ENV === 'development') {
+ config.workersCount = 0;
+}
// Renderer detects a total number of CPUs on virtual hostings like Heroku or CircleCI instead
// of CPUs number allocated for current container. This results in spawning many workers while
// only 1-2 of them really needed.
-if (process.env.CI) {
+else if (process.env.CI) {
config.workersCount = 2;
}
diff --git a/docs/react-server-components-inside-client-components.md b/docs/react-server-components-inside-client-components.md
new file mode 100644
index 000000000..62184e53c
--- /dev/null
+++ b/docs/react-server-components-inside-client-components.md
@@ -0,0 +1,332 @@
+
+# Using React Server Components Inside Client Components
+
+React on Rails now supports rendering React Server Components (RSC) directly inside React Client Components. This guide explains how to use this feature effectively in your applications.
+
+## Overview
+
+React Server Components provide several benefits.However, React traditionally doesn't allow server components to be directly rendered inside client components. This feature bypasses that limitation.
+
+> [!IMPORTANT]
+> This feature should be used judiciously. It's best suited for server components whose props change very rarely, such as router routes. **Do not** use this with components whose props change frequently as it triggers HTTP requests to the server on each re-render.
+
+## Basic Usage
+
+### Before
+
+Previously, server components could only be embedded inside client components if passed as a prop from a parent server component:
+
+```tsx
+// Parent Server Component
+export default function Parent() {
+ return (
+
Exception in rendering[\s\S.]*Sync error from AsyncComponentsTreeForTesting[\s\S.]*<\/pre>/,
+ expect(jsonChunks.length).toBeGreaterThanOrEqual(1);
+ expect(jsonChunks.length).toBeLessThanOrEqual(4);
+
+ const chunksWithError = jsonChunks.filter((chunk) => chunk.hasErrors);
+ expect(chunksWithError).toHaveLength(1);
+ expect(chunksWithError[0].renderingError.message).toMatch(
+ /Sync error from AsyncComponentsTreeForTesting/,
);
+ expect(chunksWithError[0].html).toMatch(/Sync error from AsyncComponentsTreeForTesting/);
+ expect(chunksWithError[0].isShellReady).toBeTruthy();
expect(status).toBe(200);
},
10000,
@@ -197,7 +209,6 @@ describe('html streaming', () => {
it("shouldn't notify error reporter when throwJsErrors is false and shell error happens", async () => {
await makeRequest({
props: { throwSyncError: true },
- useTestBundle: true,
// throwJsErrors is false by default
});
expect(errorReporter.message).not.toHaveBeenCalled();
@@ -206,10 +217,10 @@ describe('html streaming', () => {
it('should notify error reporter when throwJsErrors is true and shell error happens', async () => {
await makeRequest({
props: { throwSyncError: true },
- useTestBundle: true,
throwJsErrors: true,
});
- expect(errorReporter.message).toHaveBeenCalledTimes(1);
+ // Reporter is called twice: once for the error occured at RSC vm and the other while rendering the errornous rsc payload
+ expect(errorReporter.message).toHaveBeenCalledTimes(2);
expect(errorReporter.message).toHaveBeenCalledWith(
expect.stringMatching(
/Error in a rendering stream[\s\S.]*Sync error from AsyncComponentsTreeForTesting/,
@@ -222,7 +233,6 @@ describe('html streaming', () => {
async (throwJsErrors) => {
const { status, chunks, fullBody, jsonChunks } = await makeRequest({
props: { throwAsyncError: true },
- useTestBundle: true,
throwJsErrors,
});
expect(chunks.length).toBeGreaterThan(5);
@@ -237,9 +247,16 @@ describe('html streaming', () => {
expect(fullBody).toContain('branch2 (level 1)');
expect(fullBody).toContain('branch2 (level 0)');
- expect(jsonChunks[0].hasErrors).toBeFalsy();
- // All chunks after the first one should have errors
- expect(jsonChunks.slice(1).every((chunk) => chunk.hasErrors)).toBeTruthy();
+ expect(jsonChunks[0].isShellReady).toBeTruthy();
+ expect(jsonChunks[0].hasErrors).toBeTruthy();
+ expect(jsonChunks[0].renderingError).toMatchObject({
+ message: 'Async error from AsyncHelloWorldHooks',
+ stack: expect.stringMatching(
+ /Error: Async error from AsyncHelloWorldHooks\s*at AsyncHelloWorldHooks/,
+ ),
+ });
+ expect(jsonChunks.slice(1).some((chunk) => chunk.hasErrors)).toBeFalsy();
+ expect(jsonChunks.slice(1).some((chunk) => chunk.renderingError)).toBeFalsy();
},
10000,
);
@@ -247,7 +264,6 @@ describe('html streaming', () => {
it('should not notify error reporter when throwJsErrors is false and async error happens', async () => {
await makeRequest({
props: { throwAsyncError: true },
- useTestBundle: true,
throwJsErrors: false,
});
expect(errorReporter.message).not.toHaveBeenCalled();
@@ -256,10 +272,10 @@ describe('html streaming', () => {
it('should notify error reporter when throwJsErrors is true and async error happens', async () => {
await makeRequest({
props: { throwAsyncError: true },
- useTestBundle: true,
throwJsErrors: true,
});
- expect(errorReporter.message).toHaveBeenCalledTimes(1);
+ // Reporter is called twice: once for the error occured at RSC vm and the other while rendering the errornous rsc payload
+ expect(errorReporter.message).toHaveBeenCalledTimes(2);
expect(errorReporter.message).toHaveBeenCalledWith(
expect.stringMatching(/Error in a rendering stream[\s\S.]*Async error from AsyncHelloWorldHooks/),
);
diff --git a/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js b/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js
new file mode 100644
index 000000000..8b7a791b7
--- /dev/null
+++ b/packages/node-renderer/tests/serverRenderRSCReactComponent.test.js
@@ -0,0 +1,156 @@
+import path from 'path';
+import { Readable } from 'stream';
+import { buildVM, getVMContext, resetVM } from '../src/worker/vm';
+import { getConfig } from '../src/shared/configBuilder';
+
+const SimpleWorkingComponent = () => 'hello';
+
+const ComponentWithSyncError = () => {
+ throw new Error('Sync error');
+};
+
+const ComponentWithAsyncError = async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+ throw new Error('Async error');
+};
+
+describe('serverRenderRSCReactComponent', () => {
+ beforeEach(async () => {
+ const config = getConfig();
+ config.supportModules = true;
+ config.maxVMPoolSize = 2; // Set a small pool size for testing
+ config.stubTimers = false;
+ });
+
+ afterEach(async () => {
+ resetVM();
+ });
+
+ // The serverRenderRSCReactComponent function should only be called when the bundle is compiled with the `react-server` condition.
+ // Therefore, we cannot call it directly in the test files. Instead, we run the RSC bundle through the VM and call the method from there.
+ const getReactOnRailsRSCObject = async () => {
+ const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test');
+ const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js');
+ await buildVM(rscBundlePath);
+ const vmContext = getVMContext(rscBundlePath);
+ const { ReactOnRails, React } = vmContext.context;
+
+ function SuspensedComponentWithAsyncError() {
+ return React.createElement('div', null, [
+ React.createElement('div', null, 'Hello'),
+ React.createElement(
+ React.Suspense,
+ {
+ fallback: React.createElement('div', null, 'Loading Async Component...'),
+ },
+ React.createElement(ComponentWithAsyncError),
+ ),
+ ]);
+ }
+
+ ReactOnRails.register({
+ SimpleWorkingComponent,
+ ComponentWithSyncError,
+ ComponentWithAsyncError,
+ SuspensedComponentWithAsyncError,
+ });
+
+ return ReactOnRails;
+ };
+
+ const renderComponent = async (componentName, throwJsErrors = false) => {
+ const ReactOnRails = await getReactOnRailsRSCObject();
+ return ReactOnRails.serverRenderRSCReactComponent({
+ name: componentName,
+ props: {},
+ throwJsErrors,
+ railsContext: {
+ serverSide: true,
+ reactClientManifestFileName: 'react-client-manifest.json',
+ reactServerClientManifestFileName: 'react-server-client-manifest.json',
+ componentSpecificMetadata: { renderRequestId: '123' },
+ renderingReturnsPromises: true,
+ },
+ });
+ };
+
+ it('ReactOnRails should be defined and have serverRenderRSCReactComponent method', async () => {
+ const result = await getReactOnRailsRSCObject();
+ expect(result).toBeDefined();
+ expect(typeof result.serverRenderRSCReactComponent).toBe('function');
+ });
+
+ // Add these helper functions at the top of the describe block
+ const getStreamContent = async (stream) => {
+ let content = '';
+ stream.on('data', (chunk) => {
+ content += chunk.toString();
+ });
+
+ await new Promise((resolve) => {
+ stream.on('end', resolve);
+ });
+
+ return content;
+ };
+
+ const expectStreamContent = async (stream, expectedContents, options = {}) => {
+ const { throwJsErrors, expectedError } = options;
+ expect(stream).toBeDefined();
+ expect(stream).toBeInstanceOf(Readable);
+
+ const onError = throwJsErrors ? jest.fn() : null;
+ if (onError) {
+ stream.on('error', onError);
+ }
+
+ const content = await getStreamContent(stream);
+
+ if (expectedError) {
+ expect(onError).toHaveBeenCalled();
+ expect(onError).toHaveBeenCalledWith(new Error(expectedError));
+ }
+
+ expectedContents.forEach((text) => {
+ expect(content).toContain(text);
+ });
+ };
+
+ it('should returns stream with content when the component renders successfully', async () => {
+ const result = await renderComponent('SimpleWorkingComponent');
+ await expectStreamContent(result, ['hello']);
+ });
+
+ it('should returns stream with error when the component throws a sync error', async () => {
+ const result = await renderComponent('ComponentWithSyncError');
+ await expectStreamContent(result, ['Sync error']);
+ });
+
+ it('should emit an error when the component throws a sync error and throwJsErrors is true', async () => {
+ const result = await renderComponent('ComponentWithSyncError', true);
+ await expectStreamContent(result, ['Sync error'], {
+ throwJsErrors: true,
+ expectedError: 'Sync error',
+ });
+ });
+
+ it('should emit an error when the component throws an async error and throwJsErrors is true', async () => {
+ const result = await renderComponent('ComponentWithAsyncError', true);
+ await expectStreamContent(result, ['Async error'], { throwJsErrors: true, expectedError: 'Async error' });
+ });
+
+ it('should render a suspense component with an async error', async () => {
+ const result = await renderComponent('SuspensedComponentWithAsyncError');
+ await expectStreamContent(result, ['Loading Async Component...', 'Hello', 'Async error']);
+ });
+
+ it('emits an error when the suspense component throws an async error and throwJsErrors is true', async () => {
+ const result = await renderComponent('SuspensedComponentWithAsyncError', true);
+ await expectStreamContent(result, ['Loading Async Component...', 'Hello', 'Async error'], {
+ throwJsErrors: true,
+ expectedError: 'Async error',
+ });
+ });
+});
diff --git a/packages/node-renderer/tests/vm.test.ts b/packages/node-renderer/tests/vm.test.ts
index 96ca0bd00..051e5d4d9 100644
--- a/packages/node-renderer/tests/vm.test.ts
+++ b/packages/node-renderer/tests/vm.test.ts
@@ -5,6 +5,7 @@ import {
readRenderingRequest,
createVmBundle,
resetForTest,
+ BUNDLE_TIMESTAMP,
} from './helper';
import { buildVM, hasVMContextForBundle, resetVM, runInVM, getVMContext } from '../src/worker/vm';
import { getConfig } from '../src/shared/configBuilder';
@@ -184,7 +185,11 @@ describe('buildVM and runInVM', () => {
expect.assertions(1);
await createVmBundleForTest();
- expect(hasVMContextForBundle(path.resolve(__dirname, `./tmp/${testName}/1495063024898.js`))).toBeTruthy();
+ expect(
+ hasVMContextForBundle(
+ path.resolve(__dirname, `./tmp/${testName}/${BUNDLE_TIMESTAMP}/${BUNDLE_TIMESTAMP}.js`),
+ ),
+ ).toBeTruthy();
});
test('FriendsAndGuests bundle for commit 1a7fe417 requires supportModules false', async () => {
@@ -564,4 +569,142 @@ describe('buildVM and runInVM', () => {
expect(runCodeInVM3).toBe('function');
});
});
+
+ describe('VM Pool Management', () => {
+ beforeEach(async () => {
+ await resetForTest(testName);
+ const config = getConfig();
+ config.supportModules = true;
+ config.maxVMPoolSize = 2; // Set a small pool size for testing
+ });
+
+ afterEach(async () => {
+ await resetForTest(testName);
+ resetVM();
+ });
+
+ test('respects maxVMPoolSize limit', async () => {
+ const bundle1 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+ );
+ const bundle2 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+ );
+ const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js');
+
+ // Build VMs up to and beyond the pool limit
+ await buildVM(bundle1);
+ await buildVM(bundle2);
+ await buildVM(bundle3);
+
+ // Only the two most recently used bundles should have contexts
+ expect(hasVMContextForBundle(bundle1)).toBeFalsy();
+ expect(hasVMContextForBundle(bundle2)).toBeTruthy();
+ expect(hasVMContextForBundle(bundle3)).toBeTruthy();
+ });
+
+ test('calling buildVM with the same bundle path does not create a new VM', async () => {
+ const bundle1 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+ );
+ const bundle2 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+ );
+ await buildVM(bundle1);
+ await buildVM(bundle2);
+ await buildVM(bundle2);
+ await buildVM(bundle2);
+
+ expect(hasVMContextForBundle(bundle1)).toBeTruthy();
+ expect(hasVMContextForBundle(bundle2)).toBeTruthy();
+ });
+
+ test('updates lastUsed timestamp when accessing existing VM', async () => {
+ const bundle1 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+ );
+ const bundle2 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+ );
+ const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js');
+
+ // Create initial VMs
+ await buildVM(bundle1);
+ await buildVM(bundle2);
+
+ // Wait a bit to ensure timestamp difference
+ await new Promise((resolve) => {
+ setTimeout(resolve, 100);
+ });
+
+ // Access bundle1 again to update its timestamp
+ await buildVM(bundle1);
+
+ // Add a new VM - should remove bundle2 as it's the oldest
+ await buildVM(bundle3);
+
+ // Bundle1 should still exist as it was accessed more recently
+ expect(hasVMContextForBundle(bundle1)).toBeTruthy();
+ expect(hasVMContextForBundle(bundle2)).toBeFalsy();
+ expect(hasVMContextForBundle(bundle3)).toBeTruthy();
+ });
+
+ test('updates lastUsed timestamp when running code in VM', async () => {
+ const bundle1 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+ );
+ const bundle2 = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/e5e10d1/server-bundle-node-target.js',
+ );
+ const bundle3 = path.resolve(__dirname, './fixtures/projects/bionicworkshop/fa6ccf6b/server-bundle.js');
+
+ // Create initial VMs
+ await buildVM(bundle1);
+ await buildVM(bundle2);
+
+ // Wait a bit to ensure timestamp difference
+ await new Promise((resolve) => {
+ setTimeout(resolve, 100);
+ });
+
+ // Run code in bundle1 to update its timestamp
+ await runInVM('1 + 1', bundle1);
+
+ // Add a new VM - should remove bundle2 as it's the oldest
+ await buildVM(bundle3);
+
+ // Bundle1 should still exist as it was used more recently
+ expect(hasVMContextForBundle(bundle1)).toBeTruthy();
+ expect(hasVMContextForBundle(bundle2)).toBeFalsy();
+ expect(hasVMContextForBundle(bundle3)).toBeTruthy();
+ });
+
+ test('reuses existing VM context', async () => {
+ const bundle = path.resolve(
+ __dirname,
+ './fixtures/projects/spec-dummy/9fa89f7/server-bundle-web-target.js',
+ );
+
+ // Build VM first time
+ await buildVM(bundle);
+
+ // Set a variable in the VM context
+ await runInVM('global.testVar = "test value"', bundle);
+
+ // Build VM second time - should reuse existing context
+ await buildVM(bundle);
+
+ // Variable should still exist if context was reused
+ const result = await runInVM('global.testVar', bundle);
+ expect(result).toBe('test value');
+ });
+ });
});
diff --git a/packages/node-renderer/tests/worker.test.ts b/packages/node-renderer/tests/worker.test.ts
index bbd46d0e2..8f52ab1d0 100644
--- a/packages/node-renderer/tests/worker.test.ts
+++ b/packages/node-renderer/tests/worker.test.ts
@@ -6,10 +6,12 @@ import worker, { disableHttp2 } from '../src/worker';
import packageJson from '../../../package.json';
import {
BUNDLE_TIMESTAMP,
+ SECONDARY_BUNDLE_TIMESTAMP,
createVmBundle,
resetForTest,
vmBundlePath,
getFixtureBundle,
+ getFixtureSecondaryBundle,
getFixtureAsset,
getOtherFixtureAsset,
createAsset,
@@ -59,8 +61,38 @@ describe('worker', () => {
expect(res.headers['cache-control']).toBe('public, max-age=31536000');
expect(res.payload).toBe('{"html":"Dummy Object"}');
expect(fs.existsSync(vmBundlePath(testName))).toBe(true);
- expect(fs.existsSync(assetPath(testName))).toBe(true);
- expect(fs.existsSync(assetPathOther(testName))).toBe(true);
+ expect(fs.existsSync(assetPath(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+ expect(fs.existsSync(assetPathOther(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+ });
+
+ test('POST /bundles/:bundleTimestamp/render/:renderRequestDigest', async () => {
+ const app = worker({
+ bundlePath: bundlePathForTest(),
+ });
+
+ const form = formAutoContent({
+ gemVersion,
+ protocolVersion,
+ renderingRequest: 'ReactOnRails.dummy',
+ bundle: createReadStream(getFixtureBundle()),
+ [`bundle_${SECONDARY_BUNDLE_TIMESTAMP}`]: createReadStream(getFixtureSecondaryBundle()),
+ asset1: createReadStream(getFixtureAsset()),
+ asset2: createReadStream(getOtherFixtureAsset()),
+ });
+ const res = await app
+ .inject()
+ .post(`/bundles/${BUNDLE_TIMESTAMP}/render/d41d8cd98f00b204e9800998ecf8427e`)
+ .payload(form.payload)
+ .headers(form.headers)
+ .end();
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['cache-control']).toBe('public, max-age=31536000');
+ expect(res.payload).toBe('{"html":"Dummy Object"}');
+ expect(fs.existsSync(vmBundlePath(testName))).toBe(true);
+ expect(fs.existsSync(assetPath(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+ expect(fs.existsSync(assetPathOther(testName, String(BUNDLE_TIMESTAMP)))).toBe(true);
+ expect(fs.existsSync(assetPath(testName, String(SECONDARY_BUNDLE_TIMESTAMP)))).toBe(true);
+ expect(fs.existsSync(assetPathOther(testName, String(SECONDARY_BUNDLE_TIMESTAMP)))).toBe(true);
});
test(
@@ -168,7 +200,9 @@ describe('worker', () => {
);
test('post /asset-exists when asset exists', async () => {
- await createAsset(testName);
+ const bundleHash = 'some-bundle-hash';
+ await createAsset(testName, bundleHash);
+
const app = worker({
bundlePath: bundlePathForTest(),
password: 'my_password',
@@ -181,14 +215,20 @@ describe('worker', () => {
.post(`/asset-exists?${query}`)
.payload({
password: 'my_password',
+ targetBundles: [bundleHash],
})
.end();
expect(res.statusCode).toBe(200);
- expect(res.json()).toEqual({ exists: true });
+ expect(res.json()).toEqual({
+ exists: true,
+ results: [{ bundleHash, exists: true }],
+ });
});
test('post /asset-exists when asset not exists', async () => {
- await createAsset(testName);
+ const bundleHash = 'some-bundle-hash';
+ await createAsset(testName, bundleHash);
+
const app = worker({
bundlePath: bundlePathForTest(),
password: 'my_password',
@@ -201,13 +241,63 @@ describe('worker', () => {
.post(`/asset-exists?${query}`)
.payload({
password: 'my_password',
+ targetBundles: [bundleHash],
})
.end();
expect(res.statusCode).toBe(200);
- expect(res.json()).toEqual({ exists: false });
+ expect(res.json()).toEqual({
+ exists: false,
+ results: [{ bundleHash, exists: false }],
+ });
+ });
+
+ test('post /asset-exists requires targetBundles (protocol version 2.0.0)', async () => {
+ await createAsset(testName, String(BUNDLE_TIMESTAMP));
+ const app = worker({
+ bundlePath: bundlePathForTest(),
+ password: 'my_password',
+ });
+
+ const query = querystring.stringify({ filename: 'loadable-stats.json' });
+
+ const res = await app
+ .inject()
+ .post(`/asset-exists?${query}`)
+ .payload({
+ password: 'my_password',
+ })
+ .end();
+ expect(res.statusCode).toBe(400);
+
+ expect(res.payload).toContain('No targetBundles provided');
});
test('post /upload-assets', async () => {
+ const bundleHash = 'some-bundle-hash';
+
+ const app = worker({
+ bundlePath: bundlePathForTest(),
+ password: 'my_password',
+ });
+
+ const form = formAutoContent({
+ gemVersion,
+ protocolVersion,
+ password: 'my_password',
+ targetBundles: [bundleHash],
+ asset1: createReadStream(getFixtureAsset()),
+ asset2: createReadStream(getOtherFixtureAsset()),
+ });
+ const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end();
+ expect(res.statusCode).toBe(200);
+ expect(fs.existsSync(assetPath(testName, bundleHash))).toBe(true);
+ expect(fs.existsSync(assetPathOther(testName, bundleHash))).toBe(true);
+ });
+
+ test('post /upload-assets with multiple bundles and assets', async () => {
+ const bundleHash = 'some-bundle-hash';
+ const bundleHashOther = 'some-other-bundle-hash';
+
const app = worker({
bundlePath: bundlePathForTest(),
password: 'my_password',
@@ -217,12 +307,16 @@ describe('worker', () => {
gemVersion,
protocolVersion,
password: 'my_password',
+ targetBundles: [bundleHash, bundleHashOther],
asset1: createReadStream(getFixtureAsset()),
asset2: createReadStream(getOtherFixtureAsset()),
});
+
const res = await app.inject().post(`/upload-assets`).payload(form.payload).headers(form.headers).end();
expect(res.statusCode).toBe(200);
- expect(fs.existsSync(assetPath(testName))).toBe(true);
- expect(fs.existsSync(assetPathOther(testName))).toBe(true);
+ expect(fs.existsSync(assetPath(testName, bundleHash))).toBe(true);
+ expect(fs.existsSync(assetPathOther(testName, bundleHash))).toBe(true);
+ expect(fs.existsSync(assetPath(testName, bundleHashOther))).toBe(true);
+ expect(fs.existsSync(assetPathOther(testName, bundleHashOther))).toBe(true);
});
});
diff --git a/spec/dummy/Gemfile b/spec/dummy/Gemfile
index d3adb84a0..735c6e403 100644
--- a/spec/dummy/Gemfile
+++ b/spec/dummy/Gemfile
@@ -9,5 +9,6 @@ gem "react_on_rails_pro", path: "../.."
gem "graphql"
gem "prism-rails"
+gem "redis"
gem "csso-rails", "~> 1.0"
diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock
index 2563d3150..dd04070d1 100644
--- a/spec/dummy/Gemfile.lock
+++ b/spec/dummy/Gemfile.lock
@@ -315,12 +315,16 @@ GEM
ffi (~> 1.0)
rdoc (6.12.0)
psych (>= 4.0.0)
- react_on_rails (15.0.0.alpha.2)
+ react_on_rails (15.0.0.rc.1)
addressable
connection_pool
execjs (~> 2.5)
rails (>= 5.2)
rainbow (~> 3.0)
+ redis (5.4.0)
+ redis-client (>= 0.22.0)
+ redis-client (0.24.0)
+ connection_pool
regexp_parser (2.9.2)
reline (0.6.0)
io-console (~> 0.5)
@@ -512,8 +516,9 @@ DEPENDENCIES
pry-theme
puma (~> 6)
rails (~> 7.1)
- react_on_rails (= 15.0.0.alpha.2)
+ react_on_rails (= 15.0.0.rc.1)
react_on_rails_pro!
+ redis
rspec-rails
rspec-retry
rspec_junit_formatter
diff --git a/spec/dummy/app/controllers/concerns/rsc_posts_page_over_redis_helper.rb b/spec/dummy/app/controllers/concerns/rsc_posts_page_over_redis_helper.rb
new file mode 100644
index 000000000..783e05ce4
--- /dev/null
+++ b/spec/dummy/app/controllers/concerns/rsc_posts_page_over_redis_helper.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module RscPostsPageOverRedisHelper
+ extend ActiveSupport::Concern
+
+ private
+
+ def artificial_delay
+ delay = params[:artificial_delay].to_i
+ # Cap delay to prevent DoS attacks
+ [delay, 10_000].min.clamp(0, 10_000)
+ end
+
+ def write_posts_and_comments_to_redis(redis)
+ posts = fetch_posts
+ add_posts_to_stream(redis, posts)
+ write_comments_for_posts_to_redis(redis, posts)
+ redis.xadd("stream:#{@request_id}", { "end" => "true" })
+ end
+
+ def add_posts_to_stream(redis, posts)
+ Rails.logger.info "Adding posts to stream #{@request_id}"
+ redis.xadd("stream:#{@request_id}", { ":posts" => posts.to_json })
+ end
+
+ def write_comments_for_posts_to_redis(redis, posts)
+ posts.each do |post|
+ post_comments = fetch_post_comments(post, [])
+ redis.xadd("stream:#{@request_id}", { ":comments:#{post[:id]}" => post_comments.to_json })
+ post_comments.each do |comment|
+ user = fetch_comment_user(comment)
+ redis.xadd("stream:#{@request_id}", { ":user:#{comment[:user_id]}" => user.to_json })
+ end
+ end
+ end
+
+ def fetch_posts
+ posts = Post.with_delay(artificial_delay)
+ posts.group_by { |post| post[:user_id] }.map { |_, user_posts| user_posts.first }
+ end
+
+ def fetch_post_comments(post, all_posts_comments)
+ post_id = post["id"]
+ post_comments = Comment.with_delay(artificial_delay).where(post_id: post_id)
+ all_posts_comments.concat(post_comments)
+ post_comments
+ end
+
+ def fetch_comment_user(comment)
+ user_id = comment["user_id"]
+ User.with_delay(artificial_delay).find(user_id)
+ end
+end
diff --git a/spec/dummy/app/controllers/pages_controller.rb b/spec/dummy/app/controllers/pages_controller.rb
index 9d59f7b5f..c1e3492fd 100644
--- a/spec/dummy/app/controllers/pages_controller.rb
+++ b/spec/dummy/app/controllers/pages_controller.rb
@@ -2,6 +2,7 @@
class PagesController < ApplicationController
include ReactOnRailsPro::RSCPayloadRenderer
+ include RscPostsPageOverRedisHelper
XSS_PAYLOAD = { "" => '' }.freeze
PROPS_NAME = "Mr. Server Side Rendering"
@@ -40,27 +41,54 @@ def stream_async_components_for_testing
stream_view_containing_react_components(template: "/pages/stream_async_components_for_testing")
end
- def rsc_posts_page
- stream_view_containing_react_components(template: "/pages/rsc_posts_page")
+ def rsc_posts_page_over_http
+ stream_view_containing_react_components(template: "/pages/rsc_posts_page_over_http")
end
- def posts_page # rubocop:disable Metrics/AbcSize
- artificial_delay = params[:artificial_delay] || 0
- posts = JSON.parse(HTTPX.get("http://localhost:3000/api/posts").body, symbolize_names: true)
- # pick one post per user
- posts = posts.group_by { |post| post[:user_id] }.map { |_, user_posts| user_posts.first }
- posts = posts.map do |post|
- comments = JSON.parse(HTTPX.get("http://localhost:3000/api/posts/#{post[:id]}/comments").body,
- symbolize_names: true)
- comments = comments.map do |comment|
- comment.merge(user: JSON.parse(HTTPX.get("http://localhost:3000/api/users/#{comment[:user_id]}").body,
- symbolize_names: true))
- end
- post.merge(comments: comments)
+ def rsc_posts_page_over_redis
+ @request_id = SecureRandom.uuid
+
+ redis_thread = Thread.new do
+ redis = ::Redis.new
+ write_posts_and_comments_to_redis(redis)
rescue StandardError => e
- raise "Error while fetching post #{post} #{post[:id]}: #{e.message}"
+ Rails.logger.error "Error writing posts and comments to Redis: #{e.message}"
+ Rails.logger.error e.backtrace.join("\n")
+ raise e
+ end
+
+ stream_view_containing_react_components(template: "/pages/rsc_posts_page_over_redis")
+
+ return if redis_thread.join(10)
+
+ Rails.logger.error "Redis thread timed out"
+ raise "Redis thread timed out"
+ end
+
+ def async_on_server_sync_on_client
+ @render_on_server = true
+ stream_view_containing_react_components(template: "/pages/async_on_server_sync_on_client")
+ end
+
+ def async_on_server_sync_on_client_client_render
+ @render_on_server = false
+ render "/pages/async_on_server_sync_on_client"
+ end
+
+ def server_router
+ stream_view_containing_react_components(template: "/pages/server_router")
+ end
+
+ def posts_page
+ posts = fetch_posts.as_json
+ posts.each do |post|
+ post_comments = fetch_post_comments(post, []).as_json
+ post_comments.each do |comment|
+ comment["user"] = fetch_comment_user(comment).as_json
+ end
+ post["comments"] = post_comments
end
- sleep artificial_delay.to_i / 1000 * 2
+
@posts = posts
render "/pages/posts_page"
end
diff --git a/spec/dummy/app/models/application_record.rb b/spec/dummy/app/models/application_record.rb
index 71fbba5b3..55476b269 100644
--- a/spec/dummy/app/models/application_record.rb
+++ b/spec/dummy/app/models/application_record.rb
@@ -2,4 +2,9 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
+
+ scope :with_delay, lambda { |ms|
+ sleep(ms / 1000.0)
+ all
+ }
end
diff --git a/spec/dummy/app/views/layouts/application.html.erb b/spec/dummy/app/views/layouts/application.html.erb
index e874253a8..9b57229e7 100644
--- a/spec/dummy/app/views/layouts/application.html.erb
+++ b/spec/dummy/app/views/layouts/application.html.erb
@@ -2,11 +2,13 @@
-
-
-
-
-
+ <% unless request.path.include?('posts_page') %>
+
+
+
+
+
+ <% end %>
<% if content_for?(:title) %>
<%= yield(:title) %>
<% else %>
diff --git a/spec/dummy/app/views/pages/async_on_server_sync_on_client.html.erb b/spec/dummy/app/views/pages/async_on_server_sync_on_client.html.erb
new file mode 100644
index 000000000..07d613c11
--- /dev/null
+++ b/spec/dummy/app/views/pages/async_on_server_sync_on_client.html.erb
@@ -0,0 +1,79 @@
+<%= send(@render_on_server ? :stream_react_component : :react_component, "AsyncOnServerSyncOnClient",
+ props: @app_props_server_render.merge(artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2),
+ trace: true,
+ prerender: @render_on_server,
+ id: "AsyncOnServerSyncOnClient-react-component-0") %>
+
+
+
+
+Understanding Server/Client Component Hydration Patterns
+
+This page demonstrates an important React on Rails pattern: components that behave asynchronously on the server but synchronously on the client during hydration.
+
+Component Implementation
+
+
+<%= File.read(Rails.root.join "client/app/components/AsyncOnServerSyncOnClient.tsx") %>
+
+
+Key Implementation Detail
+
+
+const ComponentToUse = typeof window === 'undefined' ? AsyncComponentOnServer : SyncComponentOnClient;
+
+
+This implementation uses environment detection to conditionally render:
+AsyncComponentOnServer: An asynchronous component that waits for promises to resolve before rendering children (server-side)SyncComponentOnClient: A synchronous component that renders children immediately (client-side)View the client-only rendering example to observe:
+ +/rsc_payload/SimpleComponent to fetch its RSC payloadThis current page demonstrates the server-rendering with client hydration pattern:
+ +When a server component is rendered via server-side rendering, the React on Rails framework optimizes performance by embedding its RSC payload directly in the HTML rather than fetching it through a separate HTTP request.
+ +Server components are integrated into client components using the RSCRoute component:
+
+
+
+The RSCRoute component leverages React on Rails to:
This optimization significantly improves initial page load performance while maintaining component integrity across server and client environments.
diff --git a/spec/dummy/app/views/pages/posts_page.html.erb b/spec/dummy/app/views/pages/posts_page.html.erb index 96bf64dcc..936391580 100644 --- a/spec/dummy/app/views/pages/posts_page.html.erb +++ b/spec/dummy/app/views/pages/posts_page.html.erb @@ -1,5 +1,9 @@ <%= react_component("PostsPage", - props: @app_props_server_render.merge(posts: @posts), + props: @app_props_server_render.merge( + artificialDelay: params[:artificial_delay] || 0, + postsCount: params[:posts_count] || 2, + posts: @posts + ), prerender: true, trace: true, cache: false, diff --git a/spec/dummy/app/views/pages/rsc_posts_page.html.erb b/spec/dummy/app/views/pages/rsc_posts_page_over_http.html.erb similarity index 74% rename from spec/dummy/app/views/pages/rsc_posts_page.html.erb rename to spec/dummy/app/views/pages/rsc_posts_page_over_http.html.erb index efdb75da1..59c35a7fb 100644 --- a/spec/dummy/app/views/pages/rsc_posts_page.html.erb +++ b/spec/dummy/app/views/pages/rsc_posts_page_over_http.html.erb @@ -1,8 +1,8 @@ -<%= stream_react_component("RSCPostsPage", +<%= stream_react_component("RSCPostsPageOverHTTP", props: @app_props_server_render.merge(artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2), prerender: true, trace: true, - id: "RSCPostsPage-react-component-0") %> + id: "RSCPostsPageOverHTTP-react-component-0") %>Artificial delay: <%= params[:artificial_delay] %>
diff --git a/spec/dummy/app/views/pages/rsc_posts_page_over_redis.html.erb b/spec/dummy/app/views/pages/rsc_posts_page_over_redis.html.erb new file mode 100644 index 000000000..0dd449eec --- /dev/null +++ b/spec/dummy/app/views/pages/rsc_posts_page_over_redis.html.erb @@ -0,0 +1,13 @@ +<%= stream_react_component("RSCPostsPageOverRedis", + props: @app_props_server_render.merge( + artificialDelay: params[:artificial_delay] || 0, + postsCount: params[:posts_count] || 2, + requestId: @request_id + ), + prerender: true, + trace: true, + id: "RSCPostsPageOverRedis-react-component-0") %> +Artificial delay: <%= params[:artificial_delay] %>
+{error?.message ?? error}
+{error.message}
{comment.body}