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

Open
wants to merge 23 commits into
base: abanoubghadban/pro465/use-rsc-payload-to-render-server-components-on-server
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9ad2741
replace RSCPayloadContainer with RSC injection utils
AbanoubGhadban Apr 15, 2025
d4f2a8a
Add renderRequestId to rendering options and context for improved req…
AbanoubGhadban Apr 16, 2025
bf46d3f
tmp
AbanoubGhadban Apr 17, 2025
3772f45
trace used rsc payload streams on server and pass them to client
AbanoubGhadban Apr 25, 2025
71629c0
replace registerRouter with wrapServerComponentRenderer utility function
AbanoubGhadban Apr 25, 2025
32f8b36
add artifical delay amd log statements
AbanoubGhadban Apr 26, 2025
94c8a32
Revert "add artifical delay amd log statements"
AbanoubGhadban Apr 26, 2025
552efd2
Update import statements to include file extensions
AbanoubGhadban Apr 26, 2025
0a36833
add missing docs and specs
AbanoubGhadban Apr 27, 2025
93a8754
remove RSCRoots and replace its test with registerServerComponent tests
AbanoubGhadban Apr 28, 2025
b26074f
Update package.json
AbanoubGhadban Apr 28, 2025
67e17c7
update injectRSCPayload tests to expect the new behavior
AbanoubGhadban Apr 28, 2025
69f90b7
fix streamServerRenderedReactComponent and helper specs
AbanoubGhadban Apr 28, 2025
25c546f
Update renderContextRows to exclude 'componentSpecificMetadata' from …
AbanoubGhadban Apr 28, 2025
3b93920
fix knip errors
AbanoubGhadban Apr 28, 2025
b371f19
Remove RSCServerRoot entry from package.json
AbanoubGhadban Apr 28, 2025
577b392
add test to test the behavior of hydrating Suspensable components
AbanoubGhadban Apr 28, 2025
63d86d8
initialize the rsc payload array in a sync manner when the generate r…
AbanoubGhadban May 2, 2025
ad10b0c
fix failing jest tests
AbanoubGhadban May 3, 2025
97f440e
add TypeScript ignore comment in SuspenseHydration test for Node 18+ …
AbanoubGhadban May 3, 2025
676381e
remove options parameter from registerServerComponent and update rela…
AbanoubGhadban May 4, 2025
5ba9634
refactor: rename WrapServerComponentRenderer to wrapServerComponentRe…
AbanoubGhadban May 4, 2025
8172e07
docs: add detailed JSDoc comments for RSC functions and utilities to …
AbanoubGhadban May 4, 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
4 changes: 1 addition & 3 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,7 @@ const config = tsEslint.config([
languageOptions: {
parserOptions: {
projectService: {
allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'],
// Needed because `import * as ... from` instead of `import ... from` doesn't work in this file
// for some imports.
Comment on lines -168 to -169
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why remove this comment?

allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.{ts,tsx}'],
defaultProject: 'tsconfig.eslint.json',
},
},
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!',
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's imported in

import RSCRoute from '../RSCRoute.ts';
so adding it as an entry shouldn't be necessary (but doesn't hurt either).

'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
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be fixed in the base PR instead.

end

def configure_skip_display_none_deprecation
Expand Down
3 changes: 2 additions & 1 deletion lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,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
6 changes: 1 addition & 5 deletions lib/react_on_rails/packs_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,10 @@ def pack_file_contents(file_path)
ReactOnRailsPro.configuration.enable_rsc_support

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
13 changes: 12 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,16 @@ 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.
@render_request_id = self.class.generate_request_id
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't understand why this is necessary now and wasn't before.

Or is it needed for RSCs in general and just missed in the previous PRs? If yes, can we easily avoid extra work for non-RSCs, even if there's little of it?

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 +146,10 @@ def store_dependencies
options[:store_dependencies]
end

def self.generate_request_id
SecureRandom.uuid
end

private

attr_reader :options
Expand Down
1 change: 1 addition & 0 deletions lib/react_on_rails/server_rendering_js_code.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
<<-JS
(function() {
var railsContext = #{rails_context};
railsContext.componentSpecificMetadata = {renderRequestId: '#{render_options.render_request_id}'};
#{redux_stores}
var props = #{props_string};
return ReactOnRails.serverRenderReactComponent({
Expand Down
5 changes: 5 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 to set it in your configuration"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
"react_server_client_manifest_file is nil, ensure to set it in your configuration"
"react_server_client_manifest_file is nil, ensure it is set in your configuration"

(or make sure).

end

@react_server_manifest_path = File.join(generated_assets_full_path, asset_name)
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');

if (!renderRequestId) {
console.error(`renderRequestId is missing for ${name} in dom node with id: ${domNodeId}`);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are the users supposed to do anything in this case?

Suggested change
console.error(`renderRequestId is missing for ${name} in dom node with id: ${domNodeId}`);
console.error(`renderRequestId is missing for component ${name} in the DOM node with id ${domNodeId}`);

}

const componentSpecificRailsContext = {
...railsContext,
componentSpecificMetadata: {
renderRequestId: renderRequestId || '',
},
};

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