Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ def stream_react_component(component_name, options = {})
end
end

def stream_react_component_with_async_props(component_name, options = {}, &props_block)
options[:async_props_block] = props_block
stream_react_component(component_name, options)
end

# Renders the React Server Component (RSC) payload for a given component. This helper generates
# a special format designed by React for serializing server components and transmitting them
# to the client.
Expand Down
3 changes: 3 additions & 0 deletions react_on_rails_pro/lib/react_on_rails_pro.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

require "rails"

# Apply HTTPX bug fix for stream_bidi plugin
require "react_on_rails_pro/httpx_stream_bidi_patch"

require "react_on_rails_pro/request"
require "react_on_rails_pro/version"
require "react_on_rails_pro/constants"
Expand Down
60 changes: 60 additions & 0 deletions react_on_rails_pro/lib/react_on_rails_pro/async_props_emitter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

module ReactOnRailsPro
# Emitter class for sending async props incrementally during streaming render
# Used by stream_react_component_with_async_props helper
class AsyncPropsEmitter
def initialize(bundle_timestamp, request_stream)
@bundle_timestamp = bundle_timestamp
@request_stream = request_stream
end

# Public API: emit.call('propName', propValue)
# Sends an update chunk to the node renderer to resolve an async prop
def call(prop_name, prop_value)
update_chunk = generate_update_chunk(prop_name, prop_value)
@request_stream << "#{update_chunk.to_json}\n"
rescue StandardError => e
Rails.logger.error do
"[ReactOnRailsPro] Failed to send async prop '#{prop_name}': #{e.message}"
end
# Continue - don't abort entire render because one prop failed
end

# Generates the chunk that should be executed when the request stream closes
# This tells the asyncPropsManager to end the stream
def end_stream_chunk
{
bundleTimestamp: @bundle_timestamp,
updateChunk: generate_end_stream_js
}
end

private

def generate_update_chunk(prop_name, value)
{
bundleTimestamp: @bundle_timestamp,
updateChunk: generate_set_prop_js(prop_name, value)
}
end

def generate_set_prop_js(prop_name, value)
<<~JS.strip
(function(){
var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager");
asyncPropsManager.setProp(#{prop_name.to_json}, #{value.to_json});
})()
JS
end

def generate_end_stream_js
<<~JS.strip
(function(){
var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager");
asyncPropsManager.endStream();
})()
JS
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

# Temporary monkey-patch for HTTPX bug with stream_bidi plugin + persistent connections
#
# Issue: When using HTTPX with both `persistent: true` and `.plugin(:stream_bidi)`,
# calling `session.close` raises NoMethodError: undefined method `inflight?` for
# an instance of HTTPX::Plugins::StreamBidi::Signal
#
# Root cause: The StreamBidi::Signal class is registered as a selectable in the
# selector but doesn't implement the `inflight?` method required by Selector#terminate
# (called during session close at lib/httpx/selector.rb:64)
#
# This patch adds the missing `inflight?` method to Signal. The method returns false
# because Signal objects are just pipe-based notification mechanisms to wake up the
# selector loop - they never have "inflight" HTTP requests or pending data buffers.
#
# The `unless method_defined?` guard ensures this patch won't override the method
# when the official fix is released, making it safe to keep in the codebase.
#
# Can be removed once httpx releases an official fix.
# Affected versions: httpx 1.5.1 (and possibly earlier)
# See: https://github.com/HoneyryderChuck/httpx/issues/XXX

module HTTPX
module Plugins
module StreamBidi
class Signal
unless method_defined?(:inflight?)
def inflight?
false
end
end
end
end
end
end
74 changes: 68 additions & 6 deletions react_on_rails_pro/lib/react_on_rails_pro/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
require "uri"
require "httpx"
require_relative "stream_request"
require_relative "async_props_emitter"

module ReactOnRailsPro
class Request # rubocop:disable Metrics/ClassLength
class << self
def reset_connection
@connection&.close
@connection = create_connection
@standard_connection&.close
@incremental_connection&.close
@standard_connection = nil
@incremental_connection = nil
end

def render_code(path, js_code, send_bundle)
Expand All @@ -27,7 +30,7 @@ def render_code_as_stream(path, js_code, is_rsc_payload:)
"rendering any RSC payload."
end

ReactOnRailsPro::StreamRequest.create do |send_bundle|
ReactOnRailsPro::StreamRequest.create do |send_bundle, _barrier|
if send_bundle
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
upload_assets
Expand All @@ -38,6 +41,45 @@ def render_code_as_stream(path, js_code, is_rsc_payload:)
end
end

def render_code_with_incremental_updates(path, js_code, async_props_block:, is_rsc_payload:)
Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" }

# Determine bundle timestamp based on RSC support
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
bundle_timestamp = is_rsc_payload ? pool.rsc_bundle_hash : pool.server_bundle_hash

ReactOnRailsPro::StreamRequest.create do |send_bundle, barrier|
if send_bundle
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
upload_assets
end

# Build bidirectional streaming request
request = incremental_connection.build_request(
"POST",
path,
headers: { "content-type" => "application/x-ndjson" },
body: []
)

# Create emitter and use it to generate initial request data
emitter = ReactOnRailsPro::AsyncPropsEmitter.new(bundle_timestamp, request)
initial_data = build_initial_incremental_request(js_code, emitter)

response = incremental_connection.request(request, stream: true)
request << "#{initial_data.to_json}\n"

# Execute async props block in background using barrier
barrier.async do
async_props_block.call(emitter)
ensure
request.close
end

response
end
end

def upload_assets
Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }

Expand Down Expand Up @@ -87,8 +129,14 @@ def asset_exists_on_vm_renderer?(filename)

private

# rubocop:disable Naming/MemoizedInstanceVariableName
def connection
@connection ||= create_connection
@standard_connection ||= create_standard_connection
end
# rubocop:enable Naming/MemoizedInstanceVariableName

def incremental_connection
@incremental_connection ||= create_incremental_connection
end

def perform_request(path, **post_options) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
Expand Down Expand Up @@ -222,7 +270,22 @@ def common_form_data
ReactOnRailsPro::Utils.common_form_data
end

def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
def build_initial_incremental_request(js_code, emitter)
common_form_data.merge(
renderingRequest: js_code,
onRequestClosedUpdateChunk: emitter.end_stream_chunk
)
end

def create_standard_connection
build_connection_config.plugin(:stream)
end

def create_incremental_connection
build_connection_config.plugin(:stream_bidi)
end

def build_connection_config # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
url = ReactOnRailsPro.configuration.renderer_url
Rails.logger.info do
"[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
Expand Down Expand Up @@ -266,7 +329,6 @@ def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
nil
end
)
.plugin(:stream)
# See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options
.with(
origin: url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ def generate_rsc_payload_js_function(render_options)
JS
end

# Generates JavaScript code for async props setup when incremental rendering is enabled
# @param render_options [Object] Options that control the rendering behavior
# @return [String] JavaScript code that sets up AsyncPropsManager or empty string
def async_props_setup_js(render_options)
return "" unless render_options.internal_option(:async_props_block)

<<-JS
if (ReactOnRails.isRSCBundle) {
var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps);
usedProps = propsWithAsyncProps;
sharedExecutionContext.set("asyncPropsManager", asyncPropManager);
}
JS
end

# Main rendering function that generates JavaScript code for server-side rendering
# @param props_string [String] JSON string of props to pass to the React component
# @param rails_context [String] JSON string of Rails context data
Expand Down Expand Up @@ -84,6 +99,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
#{ssr_pre_hook_js}
#{redux_stores}
var usedProps = typeof props === 'undefined' ? #{props_string} : props;
#{async_props_setup_js(render_options)}
return ReactOnRails[#{render_function_name}]({
name: componentName,
domNodeId: '#{render_options.dom_id}',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,27 @@ def exec_server_render_js(js_code, render_options)
end

def eval_streaming_js(js_code, render_options)
path = prepare_render_path(js_code, render_options)
ReactOnRailsPro::Request.render_code_as_stream(
path,
js_code,
is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
)
is_rsc_payload = ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
async_props_block = render_options.internal_option(:async_props_block)

if async_props_block
# Use incremental rendering when async props block is provided
path = prepare_incremental_render_path(js_code, render_options)
ReactOnRailsPro::Request.render_code_with_incremental_updates(
path,
js_code,
async_props_block: async_props_block,
is_rsc_payload: is_rsc_payload
)
else
# Use standard streaming when no async props block
path = prepare_render_path(js_code, render_options)
ReactOnRailsPro::Request.render_code_as_stream(
path,
js_code,
is_rsc_payload: is_rsc_payload
)
end
end

def eval_js(js_code, render_options, send_bundle: false)
Expand Down Expand Up @@ -96,16 +111,27 @@ def rsc_bundle_hash
end

def prepare_render_path(js_code, render_options)
# TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
# From the request path
# path = "/bundles/#{@bundle_hash}/render"
build_render_path(js_code, render_options, "render")
end

def prepare_incremental_render_path(js_code, render_options)
build_render_path(js_code, render_options, "incremental-render")
end

private

def build_render_path(js_code, render_options, endpoint)
ReactOnRailsPro::ServerRenderingPool::ProRendering
.set_request_digest_on_render_options(js_code, render_options)

rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support
is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming?
bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash
# TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
# From the request path
# path = "/bundles/#{@bundle_hash}/render"
"/bundles/#{bundle_hash}/render/#{render_options.request_digest}"

"/bundles/#{bundle_hash}/#{endpoint}/#{render_options.request_digest}"
end

def fallback_exec_js(js_code, render_options, error)
Expand Down
41 changes: 25 additions & 16 deletions react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

require "async"
require "async/barrier"

module ReactOnRailsPro
class StreamDecorator
def initialize(component)
Expand Down Expand Up @@ -92,22 +95,28 @@ def initialize(&request_block)
def each_chunk(&block)
return enum_for(:each_chunk) unless block

send_bundle = false
error_body = +""
loop do
stream_response = @request_executor.call(send_bundle)

# Chunks can be merged during streaming, so we separate them by newlines
# Also, we check the status code inside the loop block because calling `status` outside the loop block
# is blocking, it will wait for the response to be fully received
# Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details
process_response_chunks(stream_response, error_body, &block)
break
rescue HTTPX::HTTPError => e
send_bundle = handle_http_error(e, error_body, send_bundle)
rescue HTTPX::ReadTimeoutError => e
raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \
"Original error:\n#{e}\n#{e.backtrace}"
Sync do
barrier = Async::Barrier.new

send_bundle = false
error_body = +""
loop do
stream_response = @request_executor.call(send_bundle, barrier)

# Chunks can be merged during streaming, so we separate them by newlines
# Also, we check the status code inside the loop block because calling `status` outside the loop block
# is blocking, it will wait for the response to be fully received
# Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details
process_response_chunks(stream_response, error_body, &block)
break
rescue HTTPX::HTTPError => e
send_bundle = handle_http_error(e, error_body, send_bundle)
rescue HTTPX::ReadTimeoutError => e
raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \
"Original error:\n#{e}\n#{e.backtrace}"
end

barrier.wait
end
end

Expand Down
2 changes: 1 addition & 1 deletion react_on_rails_pro/spec/dummy/Procfile.dev
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Procfile for development with hot reloading of JavaScript and CSS

rails: rails s -p 3000
# rails: rails s -p 3000

# Run the hot reload server for client development
webpack-dev-server: HMR=true bin/shakapacker-dev-server
Expand Down
Loading
Loading