Skip to content
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

[WIP] Hydrate components immediately after downloading chunks #1656

Draft
wants to merge 9 commits into
base: abanoubghadban/pro362-add-support-for-RSC
Choose a base branch
from
81 changes: 59 additions & 22 deletions lib/react_on_rails/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def react_component(component_name, options = {})
# @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering
# Any other options are passed to the content tag, including the id.
def stream_react_component(component_name, options = {})
options = options.merge(force_load: true) unless options.key?(:force_load)
run_stream_inside_fiber do
internal_stream_react_component(component_name, options)
end
Expand Down Expand Up @@ -193,17 +194,18 @@ def react_component_hash(component_name, options = {})
# props: Ruby Hash or JSON string which contains the properties to pass to the redux store.
# Options
# defer: false -- pass as true if you wish to render this below your component.
def redux_store(store_name, props: {}, defer: false)
# force_load: false -- pass as true if you wish to hydrate this store immediately instead of
# waiting for the page to load.
def redux_store(store_name, props: {}, defer: false, force_load: false)
redux_store_data = { store_name: store_name,
props: props }
props: props,
force_load: force_load }
if defer
@registered_stores_defer_render ||= []
@registered_stores_defer_render << redux_store_data
registered_stores_defer_render << redux_store_data
"YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> " \
"and not <%= redux store %>"
else
@registered_stores ||= []
@registered_stores << redux_store_data
registered_stores << redux_store_data
result = render_redux_store_data(redux_store_data)
prepend_render_rails_context(result)
end
Expand All @@ -215,9 +217,9 @@ def redux_store(store_name, props: {}, defer: false)
# client side rendering of this hydration data, which is a hidden div with a matching class
# that contains a data props.
def redux_store_hydration_data
return if @registered_stores_defer_render.blank?
return if registered_stores_defer_render.blank?

@registered_stores_defer_render.reduce(+"") do |accum, redux_store_data|
registered_stores_defer_render.reduce(+"") do |accum, redux_store_data|
accum << render_redux_store_data(redux_store_data)
end.html_safe
end
Expand Down Expand Up @@ -400,6 +402,25 @@ def run_stream_inside_fiber
rendering_fiber.resume
end

def registered_stores
@registered_stores ||= []
end

def registered_stores_defer_render
@registered_stores_defer_render ||= []
end

def registered_stores_including_deferred
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_including_deferred.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 @@ -510,7 +531,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)
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output,
console_script)
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
html_content = <<~HTML
#{rendered_output}
Expand Down Expand Up @@ -546,18 +568,20 @@ 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.
component_specification_tag = content_tag(:script,
json_safe_and_pretty(render_options.client_props).html_safe,
type: "application/json",
class: "js-react-on-rails-component",
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,
"data-force-load" => (render_options.force_load ? true : nil))

if render_options.force_load
component_specification_tag.concat(
Expand All @@ -579,12 +603,22 @@ def internal_react_component(react_component_name, options = {})
end

def render_redux_store_data(redux_store_data)
result = 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)
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,
"data-force-load" => (redux_store_data[:force_load] ? true : nil))

if redux_store_data[:force_load]
store_hydration_data.concat(
content_tag(:script, <<~JS.strip_heredoc.html_safe
ReactOnRails.reactOnRailsStoreLoaded('#{redux_store_data[:store_name]}');
JS
)
)
end

prepend_render_rails_context(result)
prepend_render_rails_context(store_hydration_data)
end

def props_string(props)
Expand Down Expand Up @@ -641,7 +675,7 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy
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 @@ -675,17 +709,20 @@ def server_rendered_react_component(render_options) # rubocop:disable Metrics/Cy
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_including_deferred.select do |store|
store_dependencies.include?(store[:store_name])
end

result << all_stores.each_with_object(declarations) do |redux_store_data, memo|
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
Copy link
Contributor

Choose a reason for hiding this comment

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

How do we make sure that registered stores that aren't listed in the store_dependencies don't slip through the cracks?

Should we throw a warning if a store is registered that isn't listed in store_dependencies?

store_name = redux_store_data[:store_name]
props = props_string(redux_store_data[:props])
memo << <<-JS.strip_heredoc
Expand Down
63 changes: 61 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,72 @@ 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
start_index += 1 while start_index < content_length && content[start_index].match?(/\s/)

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
Copy link
Contributor

Choose a reason for hiding this comment

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


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

if load_server_components && !is_client_entrypoint?(file_path)
import_statement = ""
rsc_rendering_url_path = ReactOnRailsPro.configuration.rsc_rendering_url_path
register_call = <<~REGISTER_CALL.strip
registerServerComponent({
rscRenderingUrlPath: "#{rsc_rendering_url_path}",
}, "#{registered_component_name}")
REGISTER_CALL
else
relative_component_path = relative_component_path_from_generated_pack(file_path)
import_statement = "import #{registered_component_name} from '#{relative_component_path}';"
register_call = "register({#{registered_component_name}})"
end

<<~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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def reset_pool
def reset_pool_if_server_bundle_was_modified
return unless ReactOnRails.configuration.development_mode

# RSC (React Server Components) bundle changes are not monitored here since:
# 1. RSC is only supported in the Pro version of React on Rails
# 2. This RubyEmbeddedJavaScript pool is used exclusively in the non-Pro version
# 3. This pool uses ExecJS for JavaScript evaluation which does not support RSC
if ReactOnRails::Utils.server_bundle_path_is_http?
return if @server_bundle_url == ReactOnRails::Utils.server_bundle_js_file_path

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ def stale_generated_files(files)
def all_compiled_assets
@all_compiled_assets ||= begin
webpack_generated_files = @webpack_generated_files.map do |bundle_name|
if bundle_name == ReactOnRails.configuration.server_bundle_js_file
ReactOnRails::Utils.server_bundle_js_file_path
if bundle_name == ReactOnRails.configuration.react_client_manifest_file
ReactOnRails::Utils.react_client_manifest_file_path
Copy link
Contributor

Choose a reason for hiding this comment

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

This doesn't make sense.

Neither the ReactOnRails.configuration.react_client_manifest_file or the ReactOnRails::Utils.react_client_manifest_file_path currently exist.

Also, I don't see why you would want to replace the server_bundle_js_file reference with a react_client_manifest_file reference when the bundle_js_file_path below basically does the same thing.

else
ReactOnRails::Utils.bundle_js_file_path(bundle_name)
end
Expand Down
50 changes: 50 additions & 0 deletions node_package/src/CallbackRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ItemRegistrationCallback } from "./types";

export default class CallbackRegistry<T> {
private registeredItems = new Map<string, T>();
private callbacks = new Map<string, Array<ItemRegistrationCallback<T>>>();

set(name: string, item: T): void {
this.registeredItems.set(name, item);

const callbacks = this.callbacks.get(name) || [];
callbacks.forEach(callback => {
setTimeout(() => callback(item), 0);
});
this.callbacks.delete(name);
}

get(name: string): T | undefined {
return this.registeredItems.get(name);
}

has(name: string): boolean {
return this.registeredItems.has(name);
}

clear(): void {
this.registeredItems.clear();
}

getAll(): Map<string, T> {
return this.registeredItems;
}

onItemRegistered(name: string, callback: ItemRegistrationCallback<T>): void {
const existingItem = this.registeredItems.get(name);
if (existingItem) {
setTimeout(() => callback(existingItem), 0);
return;
}

const callbacks = this.callbacks.get(name) || [];
callbacks.push(callback);
this.callbacks.set(name, callbacks);
}

getOrWaitForItem(name: string): Promise<T> {
return new Promise((resolve) => {
this.onItemRegistered(name, resolve);
});
}
}
Loading
Loading