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 41 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
b63596a
add needed utils to use rsc payload on the server
AbanoubGhadban Mar 17, 2025
d9eec0d
Refactor component registration and rendering logic to support `serve…
AbanoubGhadban Mar 17, 2025
f659285
Revert "Refactor component registration and rendering logic to suppor…
AbanoubGhadban Mar 17, 2025
bec4fc8
Enhance ReactOnRails options management
AbanoubGhadban Mar 17, 2025
08fd7cf
Revert "Enhance ReactOnRails options management"
AbanoubGhadban Mar 17, 2025
9e962c3
add support for returning promise of react component from render func…
AbanoubGhadban Mar 18, 2025
6f27f4a
Update ReactOnRails configuration to rename server manifest file for …
AbanoubGhadban Mar 18, 2025
33c1db2
Refactor ReactOnRails to support React Server Components (RSC) regist…
AbanoubGhadban Mar 19, 2025
0d32623
embed rsc payload inside the html page
AbanoubGhadban Mar 26, 2025
6f6c8ab
linting
AbanoubGhadban Mar 27, 2025
874eac4
fix ts errors
AbanoubGhadban Mar 27, 2025
ec35ccb
tmp
AbanoubGhadban Mar 27, 2025
be6469f
linting
AbanoubGhadban Mar 27, 2025
1483b40
linting
AbanoubGhadban Mar 27, 2025
14bf1ba
Update RSCClientRoot and related files to use RSCPayloadChunk type fo…
AbanoubGhadban Apr 4, 2025
377be74
Reset render state after processing each chunk to prevent error carry…
AbanoubGhadban Apr 4, 2025
aecebd7
Update RailsContext type to make rscPayloadGenerationUrl optional
AbanoubGhadban Apr 4, 2025
ca4dbba
Fix formatting in ReactOnRails helper for consistency
AbanoubGhadban Apr 4, 2025
a9540e2
Update error handling in createPromiseResult to return errorRenderSta…
AbanoubGhadban Apr 6, 2025
4a9d7a0
Refactor RSCServerRoot to use RailsContext in generateRSCPayload and …
AbanoubGhadban Apr 7, 2025
74b0c11
Add .yalc directory to ESLint ignore patterns
AbanoubGhadban Apr 7, 2025
6765eae
update changelog
AbanoubGhadban Apr 7, 2025
44d3758
rename __FLIGHT_DATA to REACT_ON_RAILS_RSC_PAYLOAD for improved clari…
AbanoubGhadban Apr 9, 2025
db9d080
Enhance type safety in RSCClientRoot and RSCPayloadContainer by using…
AbanoubGhadban Apr 9, 2025
4709047
convert createElement calls to jsx
AbanoubGhadban Apr 9, 2025
7d2b0d9
rename transformRSCNodeStreamAndReplayConsoleLogs to transformRSCNode…
AbanoubGhadban Apr 9, 2025
4214a6e
Enhance RSCPayloadContainer documentation on escape sequences and mod…
AbanoubGhadban Apr 9, 2025
bec40aa
refactoring
AbanoubGhadban Apr 10, 2025
f65e96a
remove unnecessary keys and streamline component rendering, enhancing…
AbanoubGhadban Apr 10, 2025
564d1eb
Update import paths in server.rsc.ts to include file extensions for i…
AbanoubGhadban Apr 26, 2025
e43fd8d
Update import statement in server.ts to use .tsx extension for RSCSer…
AbanoubGhadban Apr 26, 2025
6b2f6a2
Add ability to render server components inside client components (add…
AbanoubGhadban Jun 7, 2025
6a82137
Enhance error handling and memory management in RSC payload processing
AbanoubGhadban Jun 9, 2025
680f01b
add a bility to refetch and retry fetching of server components
AbanoubGhadban Jun 9, 2025
7b2e1f1
Update import statements in client and server components to use .tsx …
AbanoubGhadban Jun 9, 2025
5f48cc7
removed the functionality of caching components, react already caches…
AbanoubGhadban Jun 9, 2025
b9954a2
delete unused file and update knip configs
AbanoubGhadban Jun 9, 2025
8345920
Refactor RSC handling to improve performance and error management
AbanoubGhadban Jun 11, 2025
f428d6b
Fix test expectations for fetch calls in registerServerComponent test…
AbanoubGhadban Jun 11, 2025
9cd81f9
Enhance error handling in server component rendering and RSC payload …
AbanoubGhadban Jun 11, 2025
ead4a00
Update react-on-rails-rsc dependency to version 19.0.1 in package.jso…
AbanoubGhadban Jun 13, 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
25 changes: 25 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,31 @@ ReactOnRails.configure do |config|
# you should include a name that matches your bundle name in your Webpack config.
config.server_bundle_js_file = "server-bundle.js"

# When using React on Rails Pro with RSC support enabled, these configuration options work together:
#
# 1. In RORP, set `config.enable_rsc_support = true` in your react_on_rails_pro.rb initializer
#
# 2. The `rsc_bundle_js_file` (typically "rsc-bundle.js") contains only server components and
# references to client components. It's generated using the RSC Webpack Loader which transforms
# client components into references. This bundle is specifically used for generating RSC payloads
# and is configured with the `react-server` condition.
config.rsc_bundle_js_file = "rsc-bundle.js"
#
# 3. The `react_client_manifest_file` contains mappings for client components that need hydration.
# It's generated by the React Server Components Webpack plugin and is required for client-side
# hydration of components.
# This manifest file is automatically generated by the React Server Components Webpack plugin. Only set this if you've configured the plugin to use a different filename.
config.react_client_manifest_file = "react-client-manifest.json"
#
# 4. The `react_server_client_manifest_file` is used during server-side rendering with RSC to
# properly resolve references between server and client components.
#
# These files are crucial when implementing React Server Components with streaming, which offers
# benefits like reduced JavaScript bundle sizes, faster page loading, and selective hydration
# of client components.
# This manifest file is automatically generated by the React Server Components Webpack plugin. Only set this if you've configured the plugin to use a different filename.
config.react_server_client_manifest_file = "react-server-client-manifest.json"

# `prerender` means server-side rendering
# default is false. This is an option for view helpers `render_component` and `render_component_hash`.
# Set to true to change the default value to true.
Expand Down
12 changes: 10 additions & 2 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const config = tsEslint.config([
'**/node_modules/',
// fixtures
'**/fixtures/',
'**/.yalc/**/*',
]),
{
files: ['**/*.[jt]s', '**/*.[jt]sx', '**/*.[cm][jt]s'],
Expand Down Expand Up @@ -71,7 +72,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 @@ -96,6 +97,7 @@ const config = tsEslint.config([
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],

Expand Down Expand Up @@ -128,6 +130,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 All @@ -153,7 +161,7 @@ const config = tsEslint.config([
languageOptions: {
parserOptions: {
projectService: {
allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'],
allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.{ts,tsx}'],
// Needed because `import * as ... from` instead of `import ... from` doesn't work in this file
// for some imports.
defaultProject: 'tsconfig.eslint.json',
Expand Down
3 changes: 1 addition & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ export default {
}),
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/node_package/tests/jest.setup.js'],
// React Server Components tests are compatible with React 19
// That only run with node version 18 and above
// React Server Components tests require React 19 and only run with Node version 18 (`newest` in our CI matrix)
moduleNameMapper:
nodeVersion < 18
? {
Expand Down
10 changes: 7 additions & 3 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ const config: KnipConfig = {
entry: [
'node_package/src/ReactOnRails.node.ts!',
'node_package/src/ReactOnRailsRSC.ts!',
'node_package/src/registerServerComponent/client.ts!',
'node_package/src/registerServerComponent/server.ts!',
'node_package/src/RSCClientRoot.ts!',
'node_package/src/registerServerComponent/client.tsx!',
'node_package/src/registerServerComponent/server.tsx!',
'node_package/src/registerServerComponent/server.rsc.ts!',
'node_package/src/wrapServerComponentRenderer/server.tsx!',
'node_package/src/wrapServerComponentRenderer/server.rsc.tsx!',
'node_package/src/RSCRoute.tsx!',
'node_package/src/ServerComponentFetchError.ts!',
'eslint.config.ts',
],
project: ['node_package/src/**/*.[jt]s{x,}!', 'node_package/tests/**/*.[jt]s{x,}'],
Expand Down
11 changes: 8 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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any changes to configuration should have matching documentation changes

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated configuration.md

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any changes to configuration.md

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Judahmeek please check again. I merged the PR that contains the changes into this PR.

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,7 +309,8 @@ 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
end

Expand Down
9 changes: 8 additions & 1 deletion lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,14 @@ 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

if ReactOnRails::Utils.rsc_support_enabled?
rsc_payload_url = ReactOnRailsPro.configuration.rsc_payload_generation_url_path
result[:rscPayloadGenerationUrlPath] = rsc_payload_url
end
end

if defined?(request) && request.present?
Expand Down Expand Up @@ -644,7 +650,8 @@ def internal_react_component(react_component_name, options = {})
"data-trace" => (render_options.trace ? true : nil),
"data-dom-id" => render_options.dom_id,
"data-store-dependencies" => render_options.store_dependencies&.to_json,
"data-force-load" => (render_options.force_load ? true : nil))
"data-force-load" => (render_options.force_load ? true : nil),
"data-render-request-id" => render_options.render_request_id)

if render_options.force_load
component_specification_tag.concat(
Expand Down
12 changes: 3 additions & 9 deletions lib/react_on_rails/packs_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,13 @@ def client_entrypoint?(file_path)

def pack_file_contents(file_path)
registered_component_name = component_name(file_path)
load_server_components = ReactOnRails::Utils.react_on_rails_pro? &&
ReactOnRailsPro.configuration.enable_rsc_support
load_server_components = ReactOnRails::Utils.rsc_support_enabled?

if load_server_components && !client_entrypoint?(file_path)
rsc_payload_generation_url_path = ReactOnRailsPro.configuration.rsc_payload_generation_url_path

return <<~FILE_CONTENT.strip
import registerServerComponent from 'react-on-rails/registerServerComponent/client';

registerServerComponent({
rscPayloadGenerationUrlPath: "#{rsc_payload_generation_url_path}",
}, "#{registered_component_name}")
registerServerComponent("#{registered_component_name}");
FILE_CONTENT
end

Expand Down Expand Up @@ -146,8 +141,7 @@ def generated_server_pack_file_content
"import #{name} from '#{relative_path(generated_server_bundle_file_path, component_path)}';"
end

load_server_components = ReactOnRails::Utils.react_on_rails_pro? &&
ReactOnRailsPro.configuration.enable_rsc_support
load_server_components = ReactOnRails::Utils.rsc_support_enabled?
server_components = component_for_server_registration_to_path.keys.delete_if do |name|
next true unless load_server_components

Expand Down
14 changes: 13 additions & 1 deletion lib/react_on_rails/react_component/render_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,17 @@ class RenderOptions
def initialize(react_component_name: required("react_component_name"), options: required("options"))
@react_component_name = react_component_name.camelize
@options = options
# The render_request_id serves as a unique identifier for each render request.
# We cannot rely solely on dom_id, as it should be unique for each component on the page,
# but the server can render the same page multiple times concurrently for different users.
# Therefore, we need an additional unique identifier that can be used both on the client and server.
# This ID can also be used to associate specific data with a particular rendered component
# on either the server or client.
# This ID is only present if RSC support is enabled because it's only used in that case.
@render_request_id = self.class.generate_request_id if ReactOnRails::Utils.rsc_support_enabled?
end

attr_reader :react_component_name
attr_reader :react_component_name, :render_request_id

def throw_js_errors
options.fetch(:throw_js_errors, false)
Expand Down Expand Up @@ -139,6 +147,10 @@ def store_dependencies
options[:store_dependencies]
end

def self.generate_request_id
SecureRandom.uuid
end

private

attr_reader :options
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
23 changes: 23 additions & 0 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ 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
if asset_name.nil?
raise ReactOnRails::Error,
"react_server_client_manifest_file is nil, ensure it is set in your configuration"
end

@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 Expand Up @@ -199,6 +213,15 @@ def self.react_on_rails_pro_version
end
end

def self.rsc_support_enabled?
return false unless react_on_rails_pro?

return @rsc_support_enabled if defined?(@rsc_support_enabled)

rorp_config = ReactOnRailsPro.configuration
@rsc_support_enabled = rorp_config.respond_to?(:enable_rsc_support) && rorp_config.enable_rsc_support
end

def self.full_text_errors_enabled?
ENV["FULL_TEXT_ERRORS"] == "true"
end
Expand Down
16 changes: 14 additions & 2 deletions node_package/src/ClientSideRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ class ComponentRenderer {
const { domNodeId } = this;
const props = el.textContent !== null ? (JSON.parse(el.textContent) as Record<string, unknown>) : {};
const trace = el.getAttribute('data-trace') === 'true';
const renderRequestId = el.getAttribute('data-render-request-id');

// The renderRequestId is optional and only present when React Server Components (RSC) support is enabled.
// When RSC is enabled, this ID helps track and associate server-rendered components with their client-side hydration.
const componentSpecificRailsContext = renderRequestId
? {
...railsContext,
componentSpecificMetadata: {
renderRequestId,
},
}
: railsContext;

try {
const domNode = document.getElementById(domNodeId);
Expand All @@ -93,7 +105,7 @@ class ComponentRenderer {
}

if (
(await delegateToRenderer(componentObj, props, railsContext, domNodeId, trace)) ||
(await delegateToRenderer(componentObj, props, componentSpecificRailsContext, domNodeId, trace)) ||
// @ts-expect-error The state can change while awaiting delegateToRenderer
this.state === 'unmounted'
) {
Expand All @@ -108,7 +120,7 @@ class ComponentRenderer {
props,
domNodeId,
trace,
railsContext,
railsContext: componentSpecificRailsContext,
shouldHydrate,
});

Expand Down
Loading
Loading