Skip to content

Commit

Permalink
auto register server components and immediately hydrate stores
Browse files Browse the repository at this point in the history
  • Loading branch information
AbanoubGhadban committed Dec 6, 2024
1 parent 45b51a6 commit de85fb4
Show file tree
Hide file tree
Showing 14 changed files with 575 additions and 54 deletions.
12 changes: 9 additions & 3 deletions lib/react_on_rails/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def self.configure
end

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

def self.configuration
@configuration ||= Configuration.new(
Expand Down Expand Up @@ -41,7 +42,9 @@ def self.configuration
make_generated_server_bundle_the_entrypoint: false,
defer_generated_component_packs: true,
# forces the loading of React components
force_load: false
force_load: false,
auto_load_server_components: true,
rsc_rendering_url: DEFAULT_RSC_RENDERING_URL
)
end

Expand All @@ -56,7 +59,7 @@ class Configuration
:same_bundle_for_client_and_server, :rendering_props_extension,
:make_generated_server_bundle_the_entrypoint,
:defer_generated_component_packs,
:force_load
:force_load, :auto_load_server_components, :rsc_rendering_url

# rubocop:disable Metrics/AbcSize
def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil,
Expand All @@ -71,7 +74,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
same_bundle_for_client_and_server: nil,
i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil,
random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil,
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil)
components_subdirectory: nil, auto_load_bundle: nil, force_load: nil,
auto_load_server_components: nil, rsc_rendering_url: 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 @@ -110,6 +114,8 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint
self.defer_generated_component_packs = defer_generated_component_packs
self.force_load = force_load
self.auto_load_server_components = auto_load_server_components
self.rsc_rendering_url = rsc_rendering_url
end
# rubocop:enable Metrics/AbcSize

Expand Down
85 changes: 58 additions & 27 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ module Helper
include ReactOnRails::Utils::Required

COMPONENT_HTML_KEY = "componentHtml"
ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PC"
ADD_STORE_TO_PENDING_HYDRATION_FUNCTION = "$ROR_PS"

# react_component_name: can be a React function or class component or a "Render-Function".
# "Render-Functions" differ from a React function in that they take two parameters, the
Expand Down Expand Up @@ -362,13 +364,13 @@ def load_pack_for_generated_component(react_component_name, render_options)

ReactOnRails::PackerUtils.raise_nested_entries_disabled unless ReactOnRails::PackerUtils.nested_entries?
append_javascript_pack_tag("client-bundle")
# if Rails.env.development?
# is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
# raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
# end
# append_javascript_pack_tag("generated/#{react_component_name}",
# defer: ReactOnRails.configuration.defer_generated_component_packs)
# append_stylesheet_pack_tag("generated/#{react_component_name}")
if Rails.env.development?
is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
end
append_javascript_pack_tag("generated/#{react_component_name}",
defer: ReactOnRails.configuration.defer_generated_component_packs)
append_stylesheet_pack_tag("generated/#{react_component_name}")
end

# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
Expand Down Expand Up @@ -401,6 +403,17 @@ def run_stream_inside_fiber
rendering_fiber.resume
end

def registered_stores
(@registered_stores || []) + (@registered_stores_defer_render || [])
end

def create_render_options(react_component_name, options)
# If no store dependencies are passed, default to all registered stores up till now
options[:store_dependencies] ||= registered_stores.map { |store| store[:store_name] }
ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
options: options)
end

def internal_stream_react_component(component_name, options = {})
options = options.merge(stream?: true)
result = internal_react_component(component_name, options)
Expand Down Expand Up @@ -512,12 +525,8 @@ def build_react_component_result_for_server_rendered_hash(
end

def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output, console_script, dom_id = nil)
hydrate_script = dom_id.present? ? content_tag(:script, %(
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push('#{dom_id}');
if (window.ReactOnRails) {
window.ReactOnRails.renderOrHydrateLoadedComponents();
}
).html_safe) : ""
add_component_to_pending_hydration_code = "window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION}('#{dom_id}');"
hydrate_script = dom_id.present? ? content_tag(:script, add_component_to_pending_hydration_code.html_safe) : ""
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
html_content = <<~HTML
#{rendered_output}
Expand All @@ -539,11 +548,26 @@ def rails_context_if_not_already_rendered
json_safe_and_pretty(data).html_safe,
type: "application/json",
id: "js-react-on-rails-context")

pending_hydration_script = <<~JS.strip_heredoc
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = [];
window.REACT_ON_RAILS_PENDING_STORE_NAMES = [];
window.#{ADD_COMPONENT_TO_PENDING_HYDRATION_FUNCTION} = function(domId) {
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS.push(domId);
if (window.ReactOnRails) {
window.ReactOnRails.renderOrHydrateLoadedComponents();
}
};
window.#{ADD_STORE_TO_PENDING_HYDRATION_FUNCTION} = function(storeName) {
window.REACT_ON_RAILS_PENDING_STORE_NAMES.push(storeName);
if (window.ReactOnRails) {
window.ReactOnRails.hydratePendingStores();
}
};
JS
rails_context_tag.concat(
content_tag(:script, %(
window.REACT_ON_RAILS_PENDING_COMPONENT_DOM_IDS = [];
).html_safe)
)
content_tag(:script, pending_hydration_script.html_safe)
).html_safe
end

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

render_options = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
options: options)
render_options = create_render_options(react_component_name, options)

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

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

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

prepend_render_rails_context(result)
prepend_render_rails_context <<~HTML
#{store_hydration_data}
#{store_hydration_script}
HTML
end

def props_string(props)
Expand Down Expand Up @@ -655,7 +685,7 @@ def server_rendered_react_component(render_options)
js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code(
props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'),
rails_context: rails_context(server_side: true).to_json,
redux_stores: initialize_redux_stores,
redux_stores: initialize_redux_stores(render_options),
react_component_name: react_component_name,
render_options: render_options
)
Expand Down Expand Up @@ -689,17 +719,18 @@ def server_rendered_react_component(render_options)
result
end

def initialize_redux_stores
def initialize_redux_stores(render_options)
result = +<<-JS
ReactOnRails.clearHydratedStores();
JS

return result unless @registered_stores.present? || @registered_stores_defer_render.present?
store_dependencies = render_options.store_dependencies
return result unless store_dependencies.present?

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

result << all_stores.each_with_object(declarations) do |redux_store_data, memo|
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
store_name = redux_store_data[:store_name]
props = props_string(redux_store_data[:props])
memo << <<-JS.strip_heredoc
Expand Down
51 changes: 49 additions & 2 deletions lib/react_on_rails/packs_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,60 @@ def create_pack(file_path)
puts(Rainbow("Generated Packs: #{output_path}").yellow)
end

def first_js_statement_in_code(content)
return "" if content.nil? || content.empty?

start_index = 0
content_length = content.length

while start_index < content_length
# Skip whitespace
while start_index < content_length && content[start_index].match?(/\s/)
start_index += 1
end

break if start_index >= content_length

current_chars = content[start_index, 2]

case current_chars
when '//'
# Single-line comment
newline_index = content.index("\n", start_index)
return "" if newline_index.nil?
start_index = newline_index + 1
when '/*'
# Multi-line comment
comment_end = content.index('*/', start_index)
return "" if comment_end.nil?
start_index = comment_end + 2
else
# Found actual content
next_line_index = content.index("\n", start_index)
return next_line_index ? content[start_index...next_line_index].strip : content[start_index..].strip
end
end

""
end

def is_client_entrypoint?(file_path)
content = File.read(file_path)
# has "use client" directive. It can be "use client" or 'use client'
first_js_statement_in_code(content).match?(/^["']use client["'](?:;|\s|$)/)
end

def pack_file_contents(file_path)
registered_component_name = component_name(file_path)
register_as_server_component = ReactOnRails.configuration.auto_load_server_components && !is_client_entrypoint?(file_path)
import_statement = register_as_server_component ? "" : "import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';"
register_call = register_as_server_component ? "registerServerComponent(\"#{registered_component_name}\")" : "register({#{registered_component_name}})";

<<~FILE_CONTENT
import ReactOnRails from 'react-on-rails';
import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';
#{import_statement}
ReactOnRails.register({#{registered_component_name}});
ReactOnRails.#{register_call};
FILE_CONTENT
end

Expand Down
4 changes: 4 additions & 0 deletions lib/react_on_rails/react_component/render_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ def rsc?
options[:rsc?]
end

def store_dependencies
options[:store_dependencies]
end

private

attr_reader :options
Expand Down
14 changes: 13 additions & 1 deletion node_package/src/ComponentRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction } from './types/index';
import React from 'react';
import type { RegisteredComponent, ReactComponentOrRenderFunction, RenderFunction, ReactComponent } from './types/index';
import isRenderFunction from './isRenderFunction';

const registeredComponents = new Map<string, RegisteredComponent>();
Expand Down Expand Up @@ -60,6 +61,17 @@ export default {
});
},

registerServerComponent(...componentNames: string[]): void {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
const RSCClientRoot = require('./RSCClientRoot').default;

const componentsWrappedInRSCClientRoot = componentNames.reduce(
(acc, name) => ({ ...acc, [name]: () => React.createElement(RSCClientRoot, { componentName: name }) }),
{}
);
this.register(componentsWrappedInRSCClientRoot);
},

/**
* @param name
* @returns { name, component, isRenderFunction, isRenderer }
Expand Down
23 changes: 23 additions & 0 deletions node_package/src/RSCClientRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import RSDWClient from 'react-server-dom-webpack/client';

if (!('use' in React)) {
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.');
}

// It's not the exact type, but it's close enough for now
type Use = <T>(promise: Promise<T>) => T;
const { use } = React as { use: Use };

const renderCache: Record<string, Promise<unknown>> = {};

const fetchRSC = ({ componentName }: { componentName: string }) => {
if (!renderCache[componentName]) {
renderCache[componentName] = RSDWClient.createFromFetch(fetch(`/rsc/${componentName}`));
}
return renderCache[componentName];
}

const RSCClientRoot = ({ componentName }: { componentName: string }) => use(fetchRSC({ componentName }));

export default RSCClientRoot;
32 changes: 32 additions & 0 deletions node_package/src/ReactOnRails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ ctx.ReactOnRails = {
ComponentRegistry.register(components);
},

/**
* Register a specific component as a server component.
* The component will not be included in the client bundle.
* When it's rendered, a call will be made to the server to render it.
* @param componentNames
*/
registerServerComponent(...componentNames: string[]): void {
ComponentRegistry.registerServerComponent(...componentNames);
},

registerStore(stores: { [id: string]: StoreGenerator }): void {
this.registerStoreGenerators(stores);
},
Expand Down Expand Up @@ -87,6 +97,24 @@ ctx.ReactOnRails = {
return StoreRegistry.getStore(name, throwIfMissing);
},

/**
* Get a store by name, or wait for it to be registered.
* @param name
* @returns Promise<Store>
*/
getOrWaitForStore(name: string): Promise<Store> {
return StoreRegistry.getOrWaitForStore(name);
},

/**
* Get a store generator by name, or wait for it to be registered.
* @param name
* @returns Promise<StoreGenerator>
*/
getOrWaitForStoreGenerator(name: string): Promise<StoreGenerator> {
return StoreRegistry.getOrWaitForStoreGenerator(name);
},

/**
* Renders or hydrates the react element passed. In case react version is >=18 will use the new api.
* @param domNode
Expand Down Expand Up @@ -140,6 +168,10 @@ ctx.ReactOnRails = {
ClientStartup.renderOrHydrateLoadedComponents();
},

hydratePendingStores(): void {
ClientStartup.hydratePendingStores();
},

reactOnRailsComponentLoaded(domId: string): void {
ClientStartup.reactOnRailsComponentLoaded(domId);
},
Expand Down
Loading

0 comments on commit de85fb4

Please sign in to comment.