Skip to content

Commit 8703f5d

Browse files
Add support for incremental rendering at ruby side (#2076)
1 parent 931d45e commit 8703f5d

File tree

18 files changed

+878
-33
lines changed

18 files changed

+878
-33
lines changed

react_on_rails_pro/app/helpers/react_on_rails_pro_helper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ def stream_react_component(component_name, options = {})
137137
end
138138
end
139139

140+
def stream_react_component_with_async_props(component_name, options = {}, &props_block)
141+
options[:async_props_block] = props_block
142+
stream_react_component(component_name, options)
143+
end
144+
140145
# Renders the React Server Component (RSC) payload for a given component. This helper generates
141146
# a special format designed by React for serializing server components and transmitting them
142147
# to the client.

react_on_rails_pro/lib/react_on_rails_pro.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
require "rails"
44

5+
# Apply HTTPX bug fix for stream_bidi plugin
6+
require "react_on_rails_pro/httpx_stream_bidi_patch"
7+
58
require "react_on_rails_pro/request"
69
require "react_on_rails_pro/version"
710
require "react_on_rails_pro/constants"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module ReactOnRailsPro
4+
# Emitter class for sending async props incrementally during streaming render
5+
# Used by stream_react_component_with_async_props helper
6+
class AsyncPropsEmitter
7+
def initialize(bundle_timestamp, request_stream)
8+
@bundle_timestamp = bundle_timestamp
9+
@request_stream = request_stream
10+
end
11+
12+
# Public API: emit.call('propName', propValue)
13+
# Sends an update chunk to the node renderer to resolve an async prop
14+
def call(prop_name, prop_value)
15+
update_chunk = generate_update_chunk(prop_name, prop_value)
16+
@request_stream << "#{update_chunk.to_json}\n"
17+
rescue StandardError => e
18+
Rails.logger.error do
19+
"[ReactOnRailsPro] Failed to send async prop '#{prop_name}': #{e.message}"
20+
end
21+
# Continue - don't abort entire render because one prop failed
22+
end
23+
24+
# Generates the chunk that should be executed when the request stream closes
25+
# This tells the asyncPropsManager to end the stream
26+
def end_stream_chunk
27+
{
28+
bundleTimestamp: @bundle_timestamp,
29+
updateChunk: generate_end_stream_js
30+
}
31+
end
32+
33+
private
34+
35+
def generate_update_chunk(prop_name, value)
36+
{
37+
bundleTimestamp: @bundle_timestamp,
38+
updateChunk: generate_set_prop_js(prop_name, value)
39+
}
40+
end
41+
42+
def generate_set_prop_js(prop_name, value)
43+
<<~JS.strip
44+
(function(){
45+
var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager");
46+
asyncPropsManager.setProp(#{prop_name.to_json}, #{value.to_json});
47+
})()
48+
JS
49+
end
50+
51+
def generate_end_stream_js
52+
<<~JS.strip
53+
(function(){
54+
var asyncPropsManager = sharedExecutionContext.get("asyncPropsManager");
55+
asyncPropsManager.endStream();
56+
})()
57+
JS
58+
end
59+
end
60+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
# Temporary monkey-patch for HTTPX bug with stream_bidi plugin + persistent connections
4+
#
5+
# Issue: When using HTTPX with both `persistent: true` and `.plugin(:stream_bidi)`,
6+
# calling `session.close` raises NoMethodError: undefined method `inflight?` for
7+
# an instance of HTTPX::Plugins::StreamBidi::Signal
8+
#
9+
# Root cause: The StreamBidi::Signal class is registered as a selectable in the
10+
# selector but doesn't implement the `inflight?` method required by Selector#terminate
11+
# (called during session close at lib/httpx/selector.rb:64)
12+
#
13+
# This patch adds the missing `inflight?` method to Signal. The method returns false
14+
# because Signal objects are just pipe-based notification mechanisms to wake up the
15+
# selector loop - they never have "inflight" HTTP requests or pending data buffers.
16+
#
17+
# The `unless method_defined?` guard ensures this patch won't override the method
18+
# when the official fix is released, making it safe to keep in the codebase.
19+
#
20+
# Can be removed once httpx releases an official fix.
21+
# Affected versions: httpx 1.5.1 (and possibly earlier)
22+
# See: https://github.com/HoneyryderChuck/httpx/issues/XXX
23+
24+
module HTTPX
25+
module Plugins
26+
module StreamBidi
27+
class Signal
28+
unless method_defined?(:inflight?)
29+
def inflight?
30+
false
31+
end
32+
end
33+
end
34+
end
35+
end
36+
end

react_on_rails_pro/lib/react_on_rails_pro/request.rb

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
require "uri"
44
require "httpx"
55
require_relative "stream_request"
6+
require_relative "async_props_emitter"
67

78
module ReactOnRailsPro
89
class Request # rubocop:disable Metrics/ClassLength
910
class << self
1011
def reset_connection
11-
@connection&.close
12-
@connection = create_connection
12+
@standard_connection&.close
13+
@incremental_connection&.close
14+
@standard_connection = nil
15+
@incremental_connection = nil
1316
end
1417

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

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

44+
def render_code_with_incremental_updates(path, js_code, async_props_block:, is_rsc_payload:)
45+
Rails.logger.info { "[ReactOnRailsPro] Perform incremental rendering request #{path}" }
46+
47+
# Determine bundle timestamp based on RSC support
48+
pool = ReactOnRailsPro::ServerRenderingPool::NodeRenderingPool
49+
bundle_timestamp = is_rsc_payload ? pool.rsc_bundle_hash : pool.server_bundle_hash
50+
51+
ReactOnRailsPro::StreamRequest.create do |send_bundle, barrier|
52+
if send_bundle
53+
Rails.logger.info { "[ReactOnRailsPro] Sending bundle to the node renderer" }
54+
upload_assets
55+
end
56+
57+
# Build bidirectional streaming request
58+
request = incremental_connection.build_request(
59+
"POST",
60+
path,
61+
headers: { "content-type" => "application/x-ndjson" },
62+
body: []
63+
)
64+
65+
# Create emitter and use it to generate initial request data
66+
emitter = ReactOnRailsPro::AsyncPropsEmitter.new(bundle_timestamp, request)
67+
initial_data = build_initial_incremental_request(js_code, emitter)
68+
69+
response = incremental_connection.request(request, stream: true)
70+
request << "#{initial_data.to_json}\n"
71+
72+
# Execute async props block in background using barrier
73+
barrier.async do
74+
async_props_block.call(emitter)
75+
ensure
76+
request.close
77+
end
78+
79+
response
80+
end
81+
end
82+
4183
def upload_assets
4284
Rails.logger.info { "[ReactOnRailsPro] Uploading assets" }
4385

@@ -87,8 +129,14 @@ def asset_exists_on_vm_renderer?(filename)
87129

88130
private
89131

132+
# rubocop:disable Naming/MemoizedInstanceVariableName
90133
def connection
91-
@connection ||= create_connection
134+
@standard_connection ||= create_standard_connection
135+
end
136+
# rubocop:enable Naming/MemoizedInstanceVariableName
137+
138+
def incremental_connection
139+
@incremental_connection ||= create_incremental_connection
92140
end
93141

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

225-
def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
273+
def build_initial_incremental_request(js_code, emitter)
274+
common_form_data.merge(
275+
renderingRequest: js_code,
276+
onRequestClosedUpdateChunk: emitter.end_stream_chunk
277+
)
278+
end
279+
280+
def create_standard_connection
281+
build_connection_config.plugin(:stream)
282+
end
283+
284+
def create_incremental_connection
285+
build_connection_config.plugin(:stream_bidi)
286+
end
287+
288+
def build_connection_config # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
226289
url = ReactOnRailsPro.configuration.renderer_url
227290
Rails.logger.info do
228291
"[ReactOnRailsPro] Setting up Node Renderer connection to #{url}"
@@ -266,7 +329,6 @@ def create_connection # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
266329
nil
267330
end
268331
)
269-
.plugin(:stream)
270332
# See https://www.rubydoc.info/gems/httpx/1.3.3/HTTPX%2FOptions:initialize for the available options
271333
.with(
272334
origin: url,

react_on_rails_pro/lib/react_on_rails_pro/server_rendering_js_code.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ def generate_rsc_payload_js_function(render_options)
4646
JS
4747
end
4848

49+
# Generates JavaScript code for async props setup when incremental rendering is enabled
50+
# @param render_options [Object] Options that control the rendering behavior
51+
# @return [String] JavaScript code that sets up AsyncPropsManager or empty string
52+
def async_props_setup_js(render_options)
53+
return "" unless render_options.internal_option(:async_props_block)
54+
55+
<<-JS
56+
if (ReactOnRails.isRSCBundle) {
57+
var { props: propsWithAsyncProps, asyncPropManager } = ReactOnRails.addAsyncPropsCapabilityToComponentProps(usedProps);
58+
usedProps = propsWithAsyncProps;
59+
sharedExecutionContext.set("asyncPropsManager", asyncPropManager);
60+
}
61+
JS
62+
end
63+
4964
# Main rendering function that generates JavaScript code for server-side rendering
5065
# @param props_string [String] JSON string of props to pass to the React component
5166
# @param rails_context [String] JSON string of Rails context data
@@ -84,6 +99,7 @@ def render(props_string, rails_context, redux_stores, react_component_name, rend
8499
#{ssr_pre_hook_js}
85100
#{redux_stores}
86101
var usedProps = typeof props === 'undefined' ? #{props_string} : props;
102+
#{async_props_setup_js(render_options)}
87103
return ReactOnRails[#{render_function_name}]({
88104
name: componentName,
89105
domNodeId: '#{render_options.dom_id}',

react_on_rails_pro/lib/react_on_rails_pro/server_rendering_pool/node_rendering_pool.rb

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,27 @@ def exec_server_render_js(js_code, render_options)
5353
end
5454

5555
def eval_streaming_js(js_code, render_options)
56-
path = prepare_render_path(js_code, render_options)
57-
ReactOnRailsPro::Request.render_code_as_stream(
58-
path,
59-
js_code,
60-
is_rsc_payload: ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
61-
)
56+
is_rsc_payload = ReactOnRailsPro.configuration.enable_rsc_support && render_options.rsc_payload_streaming?
57+
async_props_block = render_options.internal_option(:async_props_block)
58+
59+
if async_props_block
60+
# Use incremental rendering when async props block is provided
61+
path = prepare_incremental_render_path(js_code, render_options)
62+
ReactOnRailsPro::Request.render_code_with_incremental_updates(
63+
path,
64+
js_code,
65+
async_props_block: async_props_block,
66+
is_rsc_payload: is_rsc_payload
67+
)
68+
else
69+
# Use standard streaming when no async props block
70+
path = prepare_render_path(js_code, render_options)
71+
ReactOnRailsPro::Request.render_code_as_stream(
72+
path,
73+
js_code,
74+
is_rsc_payload: is_rsc_payload
75+
)
76+
end
6277
end
6378

6479
def eval_js(js_code, render_options, send_bundle: false)
@@ -96,16 +111,27 @@ def rsc_bundle_hash
96111
end
97112

98113
def prepare_render_path(js_code, render_options)
114+
# TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
115+
# From the request path
116+
# path = "/bundles/#{@bundle_hash}/render"
117+
build_render_path(js_code, render_options, "render")
118+
end
119+
120+
def prepare_incremental_render_path(js_code, render_options)
121+
build_render_path(js_code, render_options, "incremental-render")
122+
end
123+
124+
private
125+
126+
def build_render_path(js_code, render_options, endpoint)
99127
ReactOnRailsPro::ServerRenderingPool::ProRendering
100128
.set_request_digest_on_render_options(js_code, render_options)
101129

102130
rsc_support_enabled = ReactOnRailsPro.configuration.enable_rsc_support
103131
is_rendering_rsc_payload = rsc_support_enabled && render_options.rsc_payload_streaming?
104132
bundle_hash = is_rendering_rsc_payload ? rsc_bundle_hash : server_bundle_hash
105-
# TODO: Remove the request_digest. See https://github.com/shakacode/react_on_rails_pro/issues/119
106-
# From the request path
107-
# path = "/bundles/#{@bundle_hash}/render"
108-
"/bundles/#{bundle_hash}/render/#{render_options.request_digest}"
133+
134+
"/bundles/#{bundle_hash}/#{endpoint}/#{render_options.request_digest}"
109135
end
110136

111137
def fallback_exec_js(js_code, render_options, error)

react_on_rails_pro/lib/react_on_rails_pro/stream_request.rb

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
require "async"
4+
require "async/barrier"
5+
36
module ReactOnRailsPro
47
class StreamDecorator
58
def initialize(component)
@@ -92,22 +95,28 @@ def initialize(&request_block)
9295
def each_chunk(&block)
9396
return enum_for(:each_chunk) unless block
9497

95-
send_bundle = false
96-
error_body = +""
97-
loop do
98-
stream_response = @request_executor.call(send_bundle)
99-
100-
# Chunks can be merged during streaming, so we separate them by newlines
101-
# Also, we check the status code inside the loop block because calling `status` outside the loop block
102-
# is blocking, it will wait for the response to be fully received
103-
# Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details
104-
process_response_chunks(stream_response, error_body, &block)
105-
break
106-
rescue HTTPX::HTTPError => e
107-
send_bundle = handle_http_error(e, error_body, send_bundle)
108-
rescue HTTPX::ReadTimeoutError => e
109-
raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \
110-
"Original error:\n#{e}\n#{e.backtrace}"
98+
Sync do
99+
barrier = Async::Barrier.new
100+
101+
send_bundle = false
102+
error_body = +""
103+
loop do
104+
stream_response = @request_executor.call(send_bundle, barrier)
105+
106+
# Chunks can be merged during streaming, so we separate them by newlines
107+
# Also, we check the status code inside the loop block because calling `status` outside the loop block
108+
# is blocking, it will wait for the response to be fully received
109+
# Look at the spec of `status` in `spec/react_on_rails_pro/stream_spec.rb` for more details
110+
process_response_chunks(stream_response, error_body, &block)
111+
break
112+
rescue HTTPX::HTTPError => e
113+
send_bundle = handle_http_error(e, error_body, send_bundle)
114+
rescue HTTPX::ReadTimeoutError => e
115+
raise ReactOnRailsPro::Error, "Time out error while server side render streaming a component.\n" \
116+
"Original error:\n#{e}\n#{e.backtrace}"
117+
end
118+
119+
barrier.wait
111120
end
112121
end
113122

react_on_rails_pro/spec/dummy/Procfile.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Procfile for development with hot reloading of JavaScript and CSS
22

3-
rails: rails s -p 3000
3+
# rails: rails s -p 3000
44

55
# Run the hot reload server for client development
66
webpack-dev-server: HMR=true bin/shakapacker-dev-server

0 commit comments

Comments
 (0)