Skip to content

Use RSC payload to render server components on server #1696

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2d26304
add needed utils to use rsc payload on the server
AbanoubGhadban Mar 17, 2025
fa4a981
Refactor component registration and rendering logic to support `serve…
AbanoubGhadban Mar 17, 2025
c18983a
Revert "Refactor component registration and rendering logic to suppor…
AbanoubGhadban Mar 17, 2025
73fd4bb
Enhance ReactOnRails options management
AbanoubGhadban Mar 17, 2025
630dad9
Revert "Enhance ReactOnRails options management"
AbanoubGhadban Mar 17, 2025
bc19283
add support for returning promise of react component from render func…
AbanoubGhadban Mar 18, 2025
b99337b
Update ReactOnRails configuration to rename server manifest file for …
AbanoubGhadban Mar 18, 2025
d078ee2
Refactor ReactOnRails to support React Server Components (RSC) regist…
AbanoubGhadban Mar 19, 2025
245046e
embed rsc payload inside the html page
AbanoubGhadban Mar 26, 2025
b01718e
linting
AbanoubGhadban Mar 27, 2025
86abe75
fix ts errors
AbanoubGhadban Mar 27, 2025
f324ace
tmp
AbanoubGhadban Mar 27, 2025
06be6cf
linting
AbanoubGhadban Mar 27, 2025
86656e8
linting
AbanoubGhadban Mar 27, 2025
29a3cc8
Update RSCClientRoot and related files to use RSCPayloadChunk type fo…
AbanoubGhadban Apr 4, 2025
ba2ce87
Reset render state after processing each chunk to prevent error carry…
AbanoubGhadban Apr 4, 2025
da94f20
Update RailsContext type to make rscPayloadGenerationUrl optional
AbanoubGhadban Apr 4, 2025
ad0d02a
Fix formatting in ReactOnRails helper for consistency
AbanoubGhadban Apr 4, 2025
8cd1476
Update error handling in createPromiseResult to return errorRenderSta…
AbanoubGhadban Apr 6, 2025
e80ce8b
Refactor RSCServerRoot to use RailsContext in generateRSCPayload and …
AbanoubGhadban Apr 7, 2025
dc7a7d6
Add .yalc directory to ESLint ignore patterns
AbanoubGhadban Apr 7, 2025
2182ce1
update changelog
AbanoubGhadban Apr 7, 2025
553425e
rename __FLIGHT_DATA to REACT_ON_RAILS_RSC_PAYLOAD for improved clari…
AbanoubGhadban Apr 9, 2025
307ab96
Enhance type safety in RSCClientRoot and RSCPayloadContainer by using…
AbanoubGhadban Apr 9, 2025
d6628ac
convert createElement calls to jsx
AbanoubGhadban Apr 9, 2025
daa641e
rename transformRSCNodeStreamAndReplayConsoleLogs to transformRSCNode…
AbanoubGhadban Apr 9, 2025
58cd6e7
Enhance RSCPayloadContainer documentation on escape sequences and mod…
AbanoubGhadban Apr 9, 2025
bf50f57
refactoring
AbanoubGhadban Apr 10, 2025
33cc3d3
remove unnecessary keys and streamline component rendering, enhancing…
AbanoubGhadban Apr 10, 2025
c919b1d
Update import paths in server.rsc.ts to include file extensions for i…
AbanoubGhadban Apr 26, 2025
029c273
Update import statement in server.ts to use .tsx extension for RSCSer…
AbanoubGhadban Apr 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th

Changes since the last non-beta release.

#### 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.
- Introduced `RSCServerRoot` and `RSCPayloadContainer` components to enable server-side rendering (SSR) of server components using RSC payload, and to embed the RSC payload directly into the page.

[PR 1696](https://github.com/shakacode/react_on_rails/pull/1696) by [AbanoubGhadban](https://github.com/AbanoubGhadban).

#### Added

- Configuration option `generated_component_packs_loading_strategy` to control how generated component packs are loaded. It supports `sync`, `async`, and `defer` strategies. [PR 1712](https://github.com/shakacode/react_on_rails/pull/1712) by [AbanoubGhadban](https://github.com/AbanoubGhadban).
Expand Down
10 changes: 9 additions & 1 deletion eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const config = tsEslint.config([
'**/node_modules/',
// fixtures
'**/fixtures/',
'**/.yalc/**/*',
]),
{
files: ['**/*.[jt]s', '**/*.[jt]sx', '**/*.[cm][jt]s'],
Expand Down Expand Up @@ -74,7 +75,7 @@ const config = tsEslint.config([
alias: [['Assets', './spec/dummy/client/app/assets']],

node: {
extensions: ['.js', '.jsx', '.ts', '.d.ts'],
extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'],
},
},
},
Expand All @@ -99,6 +100,7 @@ const config = tsEslint.config([
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],

Expand Down Expand Up @@ -131,6 +133,12 @@ const config = tsEslint.config([
'react/jsx-props-no-spreading': 'off',
'react/static-property-placement': 'off',
'jsx-a11y/anchor-is-valid': 'off',
'react/jsx-filename-extension': [
'error',
{
extensions: ['.jsx', '.tsx'],
},
],
},
},
{
Expand Down
1 change: 1 addition & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const config: KnipConfig = {
'node_package/src/ReactOnRailsRSC.ts!',
'node_package/src/registerServerComponent/client.ts!',
'node_package/src/registerServerComponent/server.ts!',
'node_package/src/registerServerComponent/server.rsc.ts!',
'node_package/src/RSCClientRoot.ts!',
'eslint.config.ts',
],
Expand Down
13 changes: 10 additions & 3 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def self.configure

DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze
DEFAULT_REACT_CLIENT_MANIFEST_FILE = "react-client-manifest.json"
DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE = "react-server-client-manifest.json"
DEFAULT_COMPONENT_REGISTRY_TIMEOUT = 5000

def self.configuration
Expand All @@ -21,6 +22,7 @@ def self.configuration
server_bundle_js_file: "",
rsc_bundle_js_file: "",
react_client_manifest_file: DEFAULT_REACT_CLIENT_MANIFEST_FILE,
react_server_client_manifest_file: DEFAULT_REACT_SERVER_CLIENT_MANIFEST_FILE,
prerender: false,
auto_load_bundle: false,
replay_console: true,
Expand Down Expand Up @@ -66,7 +68,7 @@ class Configuration
:same_bundle_for_client_and_server, :rendering_props_extension,
:make_generated_server_bundle_the_entrypoint,
:generated_component_packs_loading_strategy, :force_load, :rsc_bundle_js_file,
:react_client_manifest_file, :component_registry_timeout
:react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout

# rubocop:disable Metrics/AbcSize
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
Expand All @@ -82,7 +84,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil,
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
rsc_bundle_js_file: nil, react_client_manifest_file: nil, component_registry_timeout: nil)
rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil,
component_registry_timeout: nil)
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
self.generated_assets_dirs = generated_assets_dirs
self.generated_assets_dir = generated_assets_dir
Expand Down Expand Up @@ -112,6 +115,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
self.server_bundle_js_file = server_bundle_js_file
self.rsc_bundle_js_file = rsc_bundle_js_file
self.react_client_manifest_file = react_client_manifest_file
self.react_server_client_manifest_file = react_server_client_manifest_file
self.same_bundle_for_client_and_server = same_bundle_for_client_and_server
self.server_renderer_pool_size = self.development_mode ? 1 : server_renderer_pool_size
self.server_renderer_timeout = server_renderer_timeout # seconds
Expand Down Expand Up @@ -305,8 +309,11 @@ def ensure_webpack_generated_files_exists
"manifest.json",
server_bundle_js_file,
rsc_bundle_js_file,
react_client_manifest_file
react_client_manifest_file,
react_server_client_manifest_file
].compact_blank

self.webpack_generated_files = files
end

def configure_skip_display_none_deprecation
Expand Down
11 changes: 9 additions & 2 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,14 @@ def json_safe_and_pretty(hash_or_string)
# second parameter passed to both component and store Render-Functions.
# This method can be called from views and from the controller, as `helpers.rails_context`
#
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def rails_context(server_side: true)
# ALERT: Keep in sync with node_package/src/types/index.ts for the properties of RailsContext
@rails_context ||= begin
rsc_url = if ReactOnRails::Utils.react_on_rails_pro?
ReactOnRailsPro.configuration.rsc_payload_generation_url_path
end

result = {
componentRegistryTimeout: ReactOnRails.configuration.component_registry_timeout,
railsEnv: Rails.env,
Expand All @@ -373,8 +377,11 @@ def rails_context(server_side: true)
# TODO: v13 just use the version if existing
rorPro: ReactOnRails::Utils.react_on_rails_pro?
}

if ReactOnRails::Utils.react_on_rails_pro?
result[:rorProVersion] = ReactOnRails::Utils.react_on_rails_pro_version

result[:rscPayloadGenerationUrl] = rsc_url if ReactOnRailsPro.configuration.enable_rsc_support
end

if defined?(request) && request.present?
Expand Down Expand Up @@ -432,7 +439,7 @@ def load_pack_for_generated_component(react_component_name, render_options)
append_stylesheet_pack_tag("generated/#{react_component_name}")
end

# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity

private

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def all_compiled_assets
webpack_generated_files = @webpack_generated_files.map do |bundle_name|
if bundle_name == ReactOnRails.configuration.react_client_manifest_file
ReactOnRails::Utils.react_client_manifest_file_path
elsif bundle_name == ReactOnRails.configuration.react_server_client_manifest_file
ReactOnRails::Utils.react_server_client_manifest_file_path
else
ReactOnRails::Utils.bundle_js_file_path(bundle_name)
end
Expand Down
9 changes: 9 additions & 0 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ def self.react_client_manifest_file_path
end
end

# React Server Manifest is generated by the server bundle.
# So, it will never be served from the dev server.
def self.react_server_client_manifest_file_path
return @react_server_manifest_path if @react_server_manifest_path && !Rails.env.development?

asset_name = ReactOnRails.configuration.react_server_client_manifest_file
@react_server_manifest_path = File.join(generated_assets_full_path, asset_name)
end

def self.running_on_windows?
(/cygwin|mswin|mingw|bccwin|wince|emx/ =~ RUBY_PLATFORM) != nil
end
Expand Down
68 changes: 63 additions & 5 deletions node_package/src/RSCClientRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import * as ReactDOMClient from 'react-dom/client';
import { createFromReadableStream } from 'react-on-rails-rsc/client';
import { fetch } from './utils.ts';
import transformRSCStreamAndReplayConsoleLogs from './transformRSCStreamAndReplayConsoleLogs.ts';
import { RailsContext, RenderFunction } from './types/index.ts';
import { RailsContext, RenderFunction, RSCPayloadChunk } from './types/index.ts';
import { ensureReactUseAvailable } from './reactApis.cts';

const { use } = React;
ensureReactUseAvailable();

if (typeof use !== 'function') {
throw new Error('React.use is not defined. Please ensure you are using React 19 to use server components.');
declare global {
interface Window {
REACT_ON_RAILS_RSC_PAYLOAD?: RSCPayloadChunk[];
}
}

export type RSCClientRootProps = {
Expand All @@ -35,6 +38,60 @@ const fetchRSC = ({ componentName, rscPayloadGenerationUrlPath, componentProps }
return createFromFetch(fetch(`/${strippedUrlPath}/${componentName}?props=${propsString}`));
};

const createRSCStreamFromPage = () => {
let streamController: ReadableStreamController<RSCPayloadChunk> | undefined;
const stream = new ReadableStream<RSCPayloadChunk>({
start(controller) {
if (typeof window === 'undefined') {
return;
}
const handleChunk = (chunk: RSCPayloadChunk) => {
controller.enqueue(chunk);
};

// The RSC payload transfer mechanism works in two possible scenarios:
// 1. RSCClientRoot executes first:
// - Initializes REACT_ON_RAILS_RSC_PAYLOAD as an empty array
// - Overrides the push function to handle incoming chunks
// - When server scripts run later, they use the overridden push function
// 2. Server scripts execute first:
// - Initialize REACT_ON_RAILS_RSC_PAYLOAD as an empty array
// - Buffer RSC payload chunks in the array
// - When RSCClientRoot runs, it reads buffered chunks and overrides push
//
// Key points:
// - The array is never reassigned, ensuring data consistency
// - The push function override ensures all chunks are properly handled
// - Execution order is irrelevant - both scenarios work correctly
if (!window.REACT_ON_RAILS_RSC_PAYLOAD) {
window.REACT_ON_RAILS_RSC_PAYLOAD = [];
}
window.REACT_ON_RAILS_RSC_PAYLOAD.forEach(handleChunk);
window.REACT_ON_RAILS_RSC_PAYLOAD.push = (...chunks) => {
chunks.forEach(handleChunk);
return chunks.length;
};
streamController = controller;
},
});

if (typeof document !== 'undefined' && document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
streamController?.close();
});
} else {
streamController?.close();
}

return stream;
};

const createFromRSCStream = () => {
const stream = createRSCStreamFromPage();
const transformedStream = transformRSCStreamAndReplayConsoleLogs(stream);
return createFromReadableStream<React.ReactNode>(transformedStream);
};

/**
* RSCClientRoot is a React component that handles client-side rendering of React Server Components (RSC).
* It manages the fetching, caching, and rendering of RSC payloads from the server.
Expand All @@ -53,7 +110,6 @@ const RSCClientRoot: RenderFunction = async (
_railsContext?: RailsContext,
domNodeId?: string,
) => {
const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps });
if (!domNodeId) {
throw new Error('RSCClientRoot: No domNodeId provided');
}
Expand All @@ -62,8 +118,10 @@ const RSCClientRoot: RenderFunction = async (
throw new Error(`RSCClientRoot: No DOM node found for id: ${domNodeId}`);
}
if (domNode.innerHTML) {
const root = await createFromRSCStream();
ReactDOMClient.hydrateRoot(domNode, root);
} else {
const root = await fetchRSC({ componentName, rscPayloadGenerationUrlPath, componentProps });
ReactDOMClient.createRoot(domNode).render(root);
}
// Added only to satisfy the return type of RenderFunction
Expand Down
Loading
Loading