Skip to content

Commit 517d389

Browse files
auto register server components and immediately hydrate stores
1 parent eac7745 commit 517d389

File tree

13 files changed

+566
-45
lines changed

13 files changed

+566
-45
lines changed

lib/react_on_rails/configuration.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ def self.configure
99
end
1010

1111
DEFAULT_GENERATED_ASSETS_DIR = File.join(%w[public webpack], Rails.env).freeze
12+
DEFAULT_RSC_RENDERING_URL = "rsc/".freeze
1213

1314
def self.configuration
1415
@configuration ||= Configuration.new(
@@ -42,7 +43,9 @@ def self.configuration
4243
make_generated_server_bundle_the_entrypoint: false,
4344
defer_generated_component_packs: true,
4445
# forces the loading of React components
45-
force_load: false
46+
force_load: false,
47+
auto_load_server_components: true,
48+
rsc_rendering_url: DEFAULT_RSC_RENDERING_URL
4649
)
4750
end
4851

@@ -57,7 +60,7 @@ class Configuration
5760
:same_bundle_for_client_and_server, :rendering_props_extension,
5861
:make_generated_server_bundle_the_entrypoint,
5962
:defer_generated_component_packs, :rsc_bundle_js_file,
60-
:force_load
63+
:force_load, :auto_load_server_components, :rsc_rendering_url
6164

6265
# rubocop:disable Metrics/AbcSize
6366
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
@@ -73,7 +76,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
7376
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil,
7477
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
7578
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
76-
rsc_bundle_js_file: nil)
79+
rsc_bundle_js_file: nil, auto_load_server_components: nil, rsc_rendering_url: nil)
7780
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
7881
self.generated_assets_dirs = generated_assets_dirs
7982
self.generated_assets_dir = generated_assets_dir
@@ -113,6 +116,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
113116
self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint
114117
self.defer_generated_component_packs = defer_generated_component_packs
115118
self.force_load = force_load
119+
self.auto_load_server_components = auto_load_server_components
120+
self.rsc_rendering_url = rsc_rendering_url
116121
end
117122
# rubocop:enable Metrics/AbcSize
118123

lib/react_on_rails/helper.rb

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ module Helper
1717
include ReactOnRails::Utils::Required
1818

1919
COMPONENT_HTML_KEY = "componentHtml"
20+
ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PC"
21+
ADD_STORE_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PS"
2022

2123
# react_component_name: can be a React function or class component or a "Render-Function".
2224
# "Render-Functions" differ from a React function in that they take two parameters, the
@@ -400,6 +402,17 @@ def run_stream_inside_fiber
400402
rendering_fiber.resume
401403
end
402404

405+
def registered_stores
406+
(@registered_stores || []) + (@registered_stores_defer_render || [])
407+
end
408+
409+
def create_render_options(react_component_name, options)
410+
# If no store dependencies are passed, default to all registered stores up till now
411+
options[:store_dependencies] ||= registered_stores.map { |store| store[:store_name] }
412+
ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
413+
options: options)
414+
end
415+
403416
def internal_stream_react_component(component_name, options = {})
404417
options = options.merge(stream?: true)
405418
result = internal_react_component(component_name, options)
@@ -511,12 +524,8 @@ def build_react_component_result_for_server_rendered_hash(
511524
end
512525

513526
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script, dom_id = nil)
514-
hydrate_script = dom_id.present? ? content_tag(:script, %(
515-
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push('#{dom_id}');
516-
if (window.ReactOnRails) {
517-
window.ReactOnRails.renderOrHydrateLoadedComponents();
518-
}
519-
).html_safe) : ""
527+
add_component_to_pending_hydration_code = "window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION}('#{dom_id}');"
528+
hydrate_script = dom_id.present? ? content_tag(:script, add_component_to_pending_hydration_code.html_safe) : ""
520529
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
521530
html_content = <<~HTML
522531
#{rendered_output}
@@ -538,11 +547,26 @@ def rails_context_if_not_already_rendered
538547
json_safe_and_pretty(data).html_safe,
539548
type: "application/json",
540549
id: "js-react-on-rails-context")
550+
551+
pending_hydration_script = <<~JS.strip_heredoc
552+
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = [];
553+
window.REACT_ON_RAILS_PENDING_STORE_NAMES = [];
554+
window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION} = function(domId) {
555+
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push(domId);
556+
if (window.ReactOnRails) {
557+
window.ReactOnRails.renderOrHydrateLoadedComponents();
558+
}
559+
};
560+
window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION} = function(storeName) {
561+
window.REACT_ON_RAILS_PENDING_STORE_NAMES.push(storeName);
562+
if (window.ReactOnRails) {
563+
window.ReactOnRails.hydratePendingStores();
564+
}
565+
};
566+
JS
541567
rails_context_tag.concat(
542-
content_tag(:script, %(
543-
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = [];
544-
).html_safe)
545-
)
568+
content_tag(:script, pending_hydration_script.html_safe)
569+
).html_safe
546570
end
547571

548572
# prepend the rails_context if not yet applied
@@ -558,8 +582,7 @@ def internal_react_component(react_component_name, options = {})
558582
# (re-hydrate the data). This enables react rendered on the client to see that the
559583
# server has already rendered the HTML.
560584

561-
render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
562-
options: options)
585+
render_options = create_render_options(react_component_name, options)
563586

564587
# Setup the page_loaded_js, which is the same regardless of prerendering or not!
565588
# The reason is that React is smart about not doing extra work if the server rendering did its job.
@@ -570,7 +593,9 @@ def internal_react_component(react_component_name, options = {})
570593
id: "js-react-on-rails-component-#{render_options.dom_id}",
571594
"data-component-name" => render_options.react_component_name,
572595
"data-trace" => (render_options.trace ? true : nil),
573-
"data-dom-id" => render_options.dom_id)
596+
"data-dom-id" => render_options.dom_id,
597+
"data-store-dependencies" => render_options.store_dependencies.to_json,
598+
)
574599

575600
if render_options.force_load
576601
component_specification_tag.concat(
@@ -592,12 +617,17 @@ def internal_react_component(react_component_name, options = {})
592617
end
593618

594619
def render_redux_store_data(redux_store_data)
595-
result = content_tag(:script,
620+
store_hydration_data = content_tag(:script,
596621
json_safe_and_pretty(redux_store_data[:props]).html_safe,
597622
type: "application/json",
598623
"data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe)
624+
hydration_code = "window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION}('#{redux_store_data[:store_name]}');"
625+
store_hydration_script = content_tag(:script, hydration_code.html_safe)
599626

600-
prepend_render_rails_context(result)
627+
prepend_render_rails_context <<~HTML
628+
#{store_hydration_data}
629+
#{store_hydration_script}
630+
HTML
601631
end
602632

603633
def props_string(props)
@@ -654,7 +684,7 @@ def server_rendered_react_component(render_options)
654684
js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code(
655685
props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'),
656686
rails_context: rails_context(server_side: true).to_json,
657-
redux_stores: initialize_redux_stores,
687+
redux_stores: initialize_redux_stores(render_options),
658688
react_component_name: react_component_name,
659689
render_options: render_options
660690
)
@@ -688,17 +718,18 @@ def server_rendered_react_component(render_options)
688718
result
689719
end
690720

691-
def initialize_redux_stores
721+
def initialize_redux_stores(render_options)
692722
result = +<<-JS
693723
ReactOnRails.clearHydratedStores();
694724
JS
695725

696-
return result unless @registered_stores.present? || @registered_stores_defer_render.present?
726+
store_dependencies = render_options.store_dependencies
727+
return result unless store_dependencies.present?
697728

698729
declarations = +"var reduxProps, store, storeGenerator;\n"
699-
all_stores = (@registered_stores || []) + (@registered_stores_defer_render || [])
730+
store_objects = registered_stores.select { |store| store_dependencies.include?(store[:store_name]) }
700731

701-
result << all_stores.each_with_object(declarations) do |redux_store_data, memo|
732+
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
702733
store_name = redux_store_data[:store_name]
703734
props = props_string(redux_store_data[:props])
704735
memo << <<-JS.strip_heredoc

lib/react_on_rails/packs_generator.rb

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,60 @@ def create_pack(file_path)
4444
puts(Rainbow("Generated Packs: #{output_path}").yellow)
4545
end
4646

47+
def first_js_statement_in_code(content)
48+
return "" if content.nil? || content.empty?
49+
50+
start_index = 0
51+
content_length = content.length
52+
53+
while start_index < content_length
54+
# Skip whitespace
55+
while start_index < content_length && content[start_index].match?(/\s/)
56+
start_index += 1
57+
end
58+
59+
break if start_index >= content_length
60+
61+
current_chars = content[start_index, 2]
62+
63+
case current_chars
64+
when '//'
65+
# Single-line comment
66+
newline_index = content.index("\n", start_index)
67+
return "" if newline_index.nil?
68+
start_index = newline_index + 1
69+
when '/*'
70+
# Multi-line comment
71+
comment_end = content.index('*/', start_index)
72+
return "" if comment_end.nil?
73+
start_index = comment_end + 2
74+
else
75+
# Found actual content
76+
next_line_index = content.index("\n", start_index)
77+
return next_line_index ? content[start_index...next_line_index].strip : content[start_index..].strip
78+
end
79+
end
80+
81+
""
82+
end
83+
84+
def is_client_entrypoint?(file_path)
85+
content = File.read(file_path)
86+
# has "use client" directive. It can be "use client" or 'use client'
87+
first_js_statement_in_code(content).match?(/^["']use client["'](?:;|\s|$)/)
88+
end
89+
4790
def pack_file_contents(file_path)
4891
registered_component_name = component_name(file_path)
92+
register_as_server_component = ReactOnRails.configuration.auto_load_server_components && !is_client_entrypoint?(file_path)
93+
import_statement = register_as_server_component ? "" : "import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';"
94+
register_call = register_as_server_component ? "registerServerComponent(\"#{registered_component_name}\")" : "register({#{registered_component_name}})";
95+
4996
<<~FILE_CONTENT
5097
import ReactOnRails from 'react-on-rails';
51-
import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';
98+
#{import_statement}
5299
53-
ReactOnRails.register({#{registered_component_name}});
100+
ReactOnRails.#{register_call};
54101
FILE_CONTENT
55102
end
56103

lib/react_on_rails/react_component/render_options.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ def rsc?
119119
options[:rsc?]
120120
end
121121

122+
def store_dependencies
123+
options[:store_dependencies]
124+
end
125+
122126
private
123127

124128
attr_reader :options

node_package/src/ComponentRegistry.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index';
1+
import React from 'react';
2+
import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction, ReactComponent } from './types/index';
23
import isRenderFunction from './isRenderFunction';
34

45
const registeredComponents = new Map<string, RegisteredComponent>();
@@ -60,6 +61,17 @@ export default {
6061
});
6162
},
6263

64+
registerServerComponent(...componentNames: string[]): void {
65+
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
66+
const RSCClientRoot = require('./RSCClientRoot').default;
67+
68+
const componentsWrappedInRSCClientRoot = componentNames.reduce(
69+
(acc, name) => ({ ...acc, [name]: () => React.createElement(RSCClientRoot, { componentName: name }) }),
70+
{}
71+
);
72+
this.register(componentsWrappedInRSCClientRoot);
73+
},
74+
6375
/**
6476
* @param name
6577
* @returns { name, component, isRenderFunction, isRenderer }

node_package/src/RSCClientRoot.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import * as React from 'react';
2+
import RSDWClient from 'react-server-dom-webpack/client';
3+
4+
if (!('use' in React)) {
5+
throw new Error('React.use is not defined. Please ensure you are using React 18.3.0-canary-670811593-20240322 or later to use server components.');
6+
}
7+
8+
// It's not the exact type, but it's close enough for now
9+
type Use = <T>(promise: Promise<T>) => T;
10+
const { use } = React as { use: Use };
11+
12+
const renderCache: Record<string, Promise<unknown>> = {};
13+
14+
const fetchRSC = ({ componentName }: { componentName: string }) => {
15+
if (!renderCache[componentName]) {
16+
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`));
17+
}
18+
return renderCache[componentName];
19+
}
20+
21+
const RSCClientRoot = ({ componentName }: { componentName: string }) => use(fetchRSC({ componentName }));
22+
23+
export default RSCClientRoot;

node_package/src/ReactOnRails.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ ctx.ReactOnRails = {
5555
ComponentRegistry.register(components);
5656
},
5757

58+
/**
59+
* Register a specific component as a server component.
60+
* The component will not be included in the client bundle.
61+
* When it's rendered, a call will be made to the server to render it.
62+
* @param componentNames
63+
*/
64+
registerServerComponent(...componentNames: string[]): void {
65+
ComponentRegistry.registerServerComponent(...componentNames);
66+
},
67+
5868
registerStore(stores: { [id: string]: StoreGenerator }): void {
5969
this.registerStoreGenerators(stores);
6070
},
@@ -87,6 +97,24 @@ ctx.ReactOnRails = {
8797
return StoreRegistry.getStore(name, throwIfMissing);
8898
},
8999

100+
/**
101+
* Get a store by name, or wait for it to be registered.
102+
* @param name
103+
* @returns Promise<Store>
104+
*/
105+
getOrWaitForStore(name: string): Promise<Store> {
106+
return StoreRegistry.getOrWaitForStore(name);
107+
},
108+
109+
/**
110+
* Get a store generator by name, or wait for it to be registered.
111+
* @param name
112+
* @returns Promise<StoreGenerator>
113+
*/
114+
getOrWaitForStoreGenerator(name: string): Promise<StoreGenerator> {
115+
return StoreRegistry.getOrWaitForStoreGenerator(name);
116+
},
117+
90118
/**
91119
* Renders or hydrates the react element passed. In case react version is >=18 will use the new api.
92120
* @param domNode
@@ -140,6 +168,10 @@ ctx.ReactOnRails = {
140168
ClientStartup.renderOrHydrateLoadedComponents();
141169
},
142170

171+
hydratePendingStores(): void {
172+
ClientStartup.hydratePendingStores();
173+
},
174+
143175
reactOnRailsComponentLoaded(domId: string): void {
144176
ClientStartup.reactOnRailsComponentLoaded(domId);
145177
},

0 commit comments

Comments
 (0)