Skip to content

Commit de85fb4

Browse files
auto register server components and immediately hydrate stores
1 parent 45b51a6 commit de85fb4

File tree

14 files changed

+575
-54
lines changed

14 files changed

+575
-54
lines changed

lib/react_on_rails/configuration.rb

Lines changed: 9 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(
@@ -41,7 +42,9 @@ def self.configuration
4142
make_generated_server_bundle_the_entrypoint: false,
4243
defer_generated_component_packs: true,
4344
# forces the loading of React components
44-
force_load: false
45+
force_load: false,
46+
auto_load_server_components: true,
47+
rsc_rendering_url: DEFAULT_RSC_RENDERING_URL
4548
)
4649
end
4750

@@ -56,7 +59,7 @@ class Configuration
5659
:same_bundle_for_client_and_server, :rendering_props_extension,
5760
:make_generated_server_bundle_the_entrypoint,
5861
:defer_generated_component_packs,
59-
:force_load
62+
:force_load, :auto_load_server_components, :rsc_rendering_url
6063

6164
# rubocop:disable Metrics/AbcSize
6265
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
@@ -71,7 +74,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
7174
same_bundle_for_client_and_server: nil,
7275
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil,
7376
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
74-
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil)
77+
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
78+
auto_load_server_components: nil, rsc_rendering_url: nil)
7579
self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root
7680
self.generated_assets_dirs = generated_assets_dirs
7781
self.generated_assets_dir = generated_assets_dir
@@ -110,6 +114,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
110114
self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint
111115
self.defer_generated_component_packs = defer_generated_component_packs
112116
self.force_load = force_load
117+
self.auto_load_server_components = auto_load_server_components
118+
self.rsc_rendering_url = rsc_rendering_url
113119
end
114120
# rubocop:enable Metrics/AbcSize
115121

lib/react_on_rails/helper.rb

Lines changed: 58 additions & 27 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
@@ -362,13 +364,13 @@ def load_pack_for_generated_component(react_component_name, render_options)
362364

363365
ReactOnRails::PackerUtils.raise_nested_entries_disabled unless ReactOnRails::PackerUtils.nested_entries?
364366
append_javascript_pack_tag("client-bundle")
365-
# if Rails.env.development?
366-
# is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
367-
# raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
368-
# end
369-
# append_javascript_pack_tag("generated/#{react_component_name}",
370-
# defer: ReactOnRails.configuration.defer_generated_component_packs)
371-
# append_stylesheet_pack_tag("generated/#{react_component_name}")
367+
if Rails.env.development?
368+
is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
369+
raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
370+
end
371+
append_javascript_pack_tag("generated/#{react_component_name}",
372+
defer: ReactOnRails.configuration.defer_generated_component_packs)
373+
append_stylesheet_pack_tag("generated/#{react_component_name}")
372374
end
373375

374376
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
@@ -401,6 +403,17 @@ def run_stream_inside_fiber
401403
rendering_fiber.resume
402404
end
403405

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

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

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

562-
render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
563-
options: options)
586+
render_options = create_render_options(react_component_name, options)
564587

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

576601
if render_options.force_load
577602
component_specification_tag.concat(
@@ -593,12 +618,17 @@ def internal_react_component(react_component_name, options = {})
593618
end
594619

595620
def render_redux_store_data(redux_store_data)
596-
result = content_tag(:script,
621+
store_hydration_data = content_tag(:script,
597622
json_safe_and_pretty(redux_store_data[:props]).html_safe,
598623
type: "application/json",
599624
"data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe)
625+
hydration_code = "window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION}('#{redux_store_data[:store_name]}');"
626+
store_hydration_script = content_tag(:script, hydration_code.html_safe)
600627

601-
prepend_render_rails_context(result)
628+
prepend_render_rails_context <<~HTML
629+
#{store_hydration_data}
630+
#{store_hydration_script}
631+
HTML
602632
end
603633

604634
def props_string(props)
@@ -655,7 +685,7 @@ def server_rendered_react_component(render_options)
655685
js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code(
656686
props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'),
657687
rails_context: rails_context(server_side: true).to_json,
658-
redux_stores: initialize_redux_stores,
688+
redux_stores: initialize_redux_stores(render_options),
659689
react_component_name: react_component_name,
660690
render_options: render_options
661691
)
@@ -689,17 +719,18 @@ def server_rendered_react_component(render_options)
689719
result
690720
end
691721

692-
def initialize_redux_stores
722+
def initialize_redux_stores(render_options)
693723
result = +<<-JS
694724
ReactOnRails.clearHydratedStores();
695725
JS
696726

697-
return result unless @registered_stores.present? || @registered_stores_defer_render.present?
727+
store_dependencies = render_options.store_dependencies
728+
return result unless store_dependencies.present?
698729

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

702-
result << all_stores.each_with_object(declarations) do |redux_store_data, memo|
733+
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
703734
store_name = redux_store_data[:store_name]
704735
props = props_string(redux_store_data[:props])
705736
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)