Skip to content

[WIP] Add ability to render server components inside client components (add support for react-router) #1736

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
b074711
replace RSCPayloadContainer with RSC injection utils
AbanoubGhadban Apr 15, 2025
9ed0edd
Add renderRequestId to rendering options and context for improved req…
AbanoubGhadban Apr 16, 2025
c99b3d3
tmp
AbanoubGhadban Apr 17, 2025
5ca2a9c
trace used rsc payload streams on server and pass them to client
AbanoubGhadban Apr 25, 2025
5b3d943
replace registerRouter with wrapServerComponentRenderer utility function
AbanoubGhadban Apr 25, 2025
58943ca
add artifical delay amd log statements
AbanoubGhadban Apr 26, 2025
0ded299
Revert "add artifical delay amd log statements"
AbanoubGhadban Apr 26, 2025
64d4395
Update import statements to include file extensions
AbanoubGhadban Apr 26, 2025
172bc00
add missing docs and specs
AbanoubGhadban Apr 27, 2025
edc7b29
remove RSCRoots and replace its test with registerServerComponent tests
AbanoubGhadban Apr 28, 2025
6977ced
update injectRSCPayload tests to expect the new behavior
AbanoubGhadban Apr 28, 2025
05f9cbb
fix streamServerRenderedReactComponent and helper specs
AbanoubGhadban Apr 28, 2025
1a452d7
Update renderContextRows to exclude 'componentSpecificMetadata' from …
AbanoubGhadban Apr 28, 2025
d4c7249
fix knip errors
AbanoubGhadban Apr 28, 2025
73ce3a6
Remove RSCServerRoot entry from package.json
AbanoubGhadban Apr 28, 2025
0639b29
add test to test the behavior of hydrating Suspensable components
AbanoubGhadban Apr 28, 2025
1dca1ca
initialize the rsc payload array in a sync manner when the generate r…
AbanoubGhadban May 2, 2025
bbc3d85
fix failing jest tests
AbanoubGhadban May 3, 2025
51d5c97
add TypeScript ignore comment in SuspenseHydration test for Node 18+ …
AbanoubGhadban May 3, 2025
f898d5c
remove options parameter from registerServerComponent and update rela…
AbanoubGhadban May 4, 2025
d45c30e
refactor: rename WrapServerComponentRenderer to wrapServerComponentRe…
AbanoubGhadban May 4, 2025
8d313aa
docs: add detailed JSDoc comments for RSC functions and utilities to …
AbanoubGhadban May 4, 2025
d8ea131
feat: implement post-SSR hooks for enhanced server-side rendering con…
AbanoubGhadban May 10, 2025
dcda9b0
refactor: improve type safety in server component loading and streaml…
AbanoubGhadban May 10, 2025
c05db93
fix: update module loading configuration to use environment-specific …
AbanoubGhadban May 10, 2025
7622849
refactor: update react-on-rails-rsc dependency to use SSR support and…
AbanoubGhadban May 10, 2025
fbbc8fe
chore: update acorn and acorn-loose dependencies in yarn.lock to late…
AbanoubGhadban May 10, 2025
33fd8a0
refactor: clarify test description for child async component hydratio…
AbanoubGhadban May 10, 2025
b69bb79
fix problem of returning the wrong stream
AbanoubGhadban May 11, 2025
e1949c4
refactor: update RailsContext types to include server component capab…
AbanoubGhadban May 18, 2025
0e4b561
refactor: rename rscPayloadGenerationUrl to rscPayloadGenerationUrlPa…
AbanoubGhadban May 18, 2025
eb262de
refactor: enhance error handling and improve code clarity in server c…
AbanoubGhadban May 18, 2025
382cbb9
handle trailing commas while removing packages from at CI "oldest" tests
AbanoubGhadban May 19, 2025
99a018b
don't run SuspenseHydration tests with CI oldest tests
AbanoubGhadban May 19, 2025
58f5a9a
chore: update testPathIgnorePatterns to exclude additional test cases…
AbanoubGhadban May 19, 2025
711d333
chore: escape quotes in testPathIgnorePatterns for proper parsing in CI
AbanoubGhadban May 19, 2025
432cad1
refactor: update RailsContext usage in tests to utilize RailsContextW…
AbanoubGhadban May 19, 2025
ad4dbe1
refactor: update test cases to use rscPayloadGenerationUrlPath for co…
AbanoubGhadban May 19, 2025
251b90c
refactor: unify stream types by introducing PipeableOrReadableStream
AbanoubGhadban May 19, 2025
448e696
refactor: replace direct checks for RSC support with a utility method…
AbanoubGhadban May 19, 2025
a8d2d1f
refactor: update stubbing in packs_generator_spec to improve clarity …
AbanoubGhadban May 19, 2025
441c901
refactor: make renderRequestId optional in server component rendering…
AbanoubGhadban May 19, 2025
8520d4e
refactor: remove renderRequestId from script tags in ReactOnRailsHelp…
AbanoubGhadban May 19, 2025
cb6ef79
refactor: optimize RSC payload handling and improve hook management f…
AbanoubGhadban May 21, 2025
f961a3b
refactor: ensure RSC support variable is reset in packs_generator_spe…
AbanoubGhadban May 22, 2025
2b1517c
refactor: enhance RSC component handling by introducing promise wrapp…
AbanoubGhadban May 24, 2025
11e4a54
refactor: make serverSideRSCPayloadParameters optional in RailsContex…
AbanoubGhadban May 24, 2025
bc4b9da
handle error happen during rsc payload generation
AbanoubGhadban May 28, 2025
3f5fb26
add rsc payload url to context only if rsc support enabled
AbanoubGhadban Jun 7, 2025
1fccd8c
removed unneeded rubocop disable statement
AbanoubGhadban Jun 7, 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
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
2 changes: 1 addition & 1 deletion eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,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
8 changes: 5 additions & 3 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ 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/registerServerComponent/client.tsx!',
'node_package/src/registerServerComponent/server.tsx!',
'node_package/src/registerServerComponent/server.rsc.ts!',
'node_package/src/RSCClientRoot.ts!',
'node_package/src/wrapServerComponentRenderer/server.tsx!',
'node_package/src/wrapServerComponentRenderer/server.rsc.tsx!',
'node_package/src/RSCRoute.ts!',
'eslint.config.ts',
],
project: ['node_package/src/**/*.[jt]s{x,}!', 'node_package/tests/**/*.[jt]s{x,}'],
Expand Down
2 changes: 0 additions & 2 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,6 @@ def ensure_webpack_generated_files_exists
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
16 changes: 8 additions & 8 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,10 @@ 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, Metrics/PerceivedComplexity
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
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 @@ -381,7 +377,10 @@ def rails_context(server_side: true)
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
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 @@ -439,7 +438,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, Metrics/PerceivedComplexity
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity

private

Expand Down Expand Up @@ -651,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
14 changes: 14 additions & 0 deletions lib/react_on_rails/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ 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

Expand Down Expand Up @@ -208,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
133 changes: 0 additions & 133 deletions node_package/src/RSCClientRoot.ts

This file was deleted.

Loading
Loading