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 8 commits into
base: abanoubghadban/pro362-add-support-for-RSC
Choose a base branch
from
11 changes: 8 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 @@ -42,7 +43,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
AbanoubGhadban marked this conversation as resolved.
Show resolved Hide resolved
)
end

Expand All @@ -57,7 +60,7 @@ class Configuration
:same_bundle_for_client_and_server, :rendering_props_extension,
:make_generated_server_bundle_the_entrypoint,
:defer_generated_component_packs, :rsc_bundle_js_file,
: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 @@ -73,7 +76,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender
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,
rsc_bundle_js_file: nil)
rsc_bundle_js_file: 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 @@ -113,6 +116,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
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|
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
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