react_on_rails 15.0.0.alpha.1 → 15.0.0.rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +479 -81
- data/CONTRIBUTING.md +61 -47
- data/Gemfile.development_dependencies +1 -1
- data/Gemfile.lock +4 -4
- data/KUDOS.md +22 -1
- data/NEWS.md +48 -48
- data/PROJECTS.md +45 -40
- data/README.md +24 -16
- data/SUMMARY.md +62 -52
- data/eslint.config.ts +213 -0
- data/knip.ts +100 -0
- data/lib/generators/USAGE +1 -1
- data/lib/generators/react_on_rails/dev_tests_generator.rb +4 -8
- data/lib/generators/react_on_rails/templates/.eslintrc +1 -1
- data/lib/generators/react_on_rails/templates/base/base/app/javascript/bundles/HelloWorld/components/HelloWorld.module.css +2 -2
- data/lib/generators/react_on_rails/templates/base/base/app/javascript/bundles/HelloWorld/components/HelloWorldServer.js +1 -1
- data/lib/generators/react_on_rails/templates/base/base/app/javascript/packs/registration.js.tt +1 -1
- data/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml +1 -1
- data/lib/generators/react_on_rails/templates/redux/base/app/javascript/bundles/HelloWorld/reducers/helloWorldReducer.js +1 -1
- data/lib/react_on_rails/configuration.rb +81 -12
- data/lib/react_on_rails/controller.rb +4 -2
- data/lib/react_on_rails/engine.rb +1 -3
- data/lib/react_on_rails/helper.rb +184 -56
- data/lib/react_on_rails/locales/base.rb +7 -1
- data/lib/react_on_rails/packer_utils.rb +23 -3
- data/lib/react_on_rails/packs_generator.rb +84 -11
- data/lib/react_on_rails/prerender_error.rb +10 -2
- data/lib/react_on_rails/react_component/render_options.rb +39 -3
- data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +6 -2
- data/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +4 -2
- data/lib/react_on_rails/utils.rb +67 -23
- data/lib/react_on_rails/version.rb +1 -1
- data/lib/react_on_rails/version_checker.rb +34 -23
- data/lib/tasks/assets.rake +1 -1
- data/react_on_rails.gemspec +2 -2
- data/tsconfig.eslint.json +6 -0
- data/tsconfig.json +10 -6
- metadata +8 -7
@@ -32,7 +32,7 @@ module ReactOnRails
|
|
32
32
|
#
|
33
33
|
# Exposing the react_component_name is necessary to both a plain ReactComponent as well as
|
34
34
|
# a generator:
|
35
|
-
# See README.md for how to "register" your
|
35
|
+
# See README.md for how to "register" your React components.
|
36
36
|
# See spec/dummy/client/app/packs/server-bundle.js and
|
37
37
|
# spec/dummy/client/app/packs/client-bundle.js for examples of this.
|
38
38
|
#
|
@@ -119,34 +119,74 @@ module ReactOnRails
|
|
119
119
|
# @option options [Hash] :props Props to pass to the react component
|
120
120
|
# @option options [String] :dom_id DOM ID of the component container
|
121
121
|
# @option options [Hash] :html_options Options passed to content_tag
|
122
|
-
# @option options [Boolean] :prerender Set to false to disable server-side rendering
|
123
122
|
# @option options [Boolean] :trace Set to true to add extra debugging information to the HTML
|
124
123
|
# @option options [Boolean] :raise_on_prerender_error Set to true to raise exceptions during server-side rendering
|
125
124
|
# Any other options are passed to the content tag, including the id.
|
126
125
|
def stream_react_component(component_name, options = {})
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
raise ReactOnRails::Error,
|
134
|
-
"You must call stream_view_containing_react_components to render the view containing the react component"
|
126
|
+
# stream_react_component doesn't have the prerender option
|
127
|
+
# Because setting prerender to false is equivalent to calling react_component with prerender: false
|
128
|
+
options[:prerender] = true
|
129
|
+
options = options.merge(force_load: true) unless options.key?(:force_load)
|
130
|
+
run_stream_inside_fiber do
|
131
|
+
internal_stream_react_component(component_name, options)
|
135
132
|
end
|
133
|
+
end
|
136
134
|
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
135
|
+
# Renders the React Server Component (RSC) payload for a given component. This helper generates
|
136
|
+
# a special format designed by React for serializing server components and transmitting them
|
137
|
+
# to the client.
|
138
|
+
#
|
139
|
+
# @return [String] Returns a Newline Delimited JSON (NDJSON) stream where each line contains a JSON object with:
|
140
|
+
# - html: The RSC payload containing the rendered server components and client component references
|
141
|
+
# - consoleReplayScript: JavaScript to replay server-side console logs in the client
|
142
|
+
# - hasErrors: Boolean indicating if any errors occurred during rendering
|
143
|
+
# - isShellReady: Boolean indicating if the initial shell is ready for hydration
|
144
|
+
#
|
145
|
+
# Example NDJSON stream:
|
146
|
+
# {"html":"<RSC Payload>","consoleReplayScript":"","hasErrors":false,"isShellReady":true}
|
147
|
+
# {"html":"<RSC Payload>","consoleReplayScript":"console.log('Loading...')","hasErrors":false,"isShellReady":true}
|
148
|
+
#
|
149
|
+
# The RSC payload within the html field contains:
|
150
|
+
# - The component's rendered output from the server
|
151
|
+
# - References to client components that need hydration
|
152
|
+
# - Data props passed to client components
|
153
|
+
#
|
154
|
+
# @param component_name [String] The name of the React component to render. This component should
|
155
|
+
# be a server component or a mixed component tree containing both server and client components.
|
156
|
+
#
|
157
|
+
# @param options [Hash] Options for rendering the component
|
158
|
+
# @option options [Hash] :props Props to pass to the component (default: {})
|
159
|
+
# @option options [Boolean] :trace Enable tracing for debugging (default: false)
|
160
|
+
# @option options [String] :id Custom DOM ID for the component container (optional)
|
161
|
+
#
|
162
|
+
# @example Basic usage with a server component
|
163
|
+
# <%= rsc_payload_react_component("ReactServerComponentPage") %>
|
164
|
+
#
|
165
|
+
# @example With props and tracing enabled
|
166
|
+
# <%= rsc_payload_react_component("RSCPostsPage",
|
167
|
+
# props: { artificialDelay: 1000 },
|
168
|
+
# trace: true) %>
|
169
|
+
#
|
170
|
+
# @note This helper requires React Server Components support to be enabled in your configuration:
|
171
|
+
# ReactOnRailsPro.configure do |config|
|
172
|
+
# config.enable_rsc_support = true
|
173
|
+
# end
|
174
|
+
#
|
175
|
+
# @raise [ReactOnRailsPro::Error] if RSC support is not enabled in configuration
|
176
|
+
#
|
177
|
+
# @note You don't have to deal directly with this helper function - it's used internally by the
|
178
|
+
# `rsc_payload_route` helper function. The returned data from this function is used internally by
|
179
|
+
# components registered using the `registerServerComponent` function. Don't use it unless you need
|
180
|
+
# more control over the RSC payload generation. To know more about RSC payload, see the following link:
|
181
|
+
# @see https://www.shakacode.com/react-on-rails-pro/docs/how-react-server-components-works.md
|
182
|
+
# for technical details about the RSC payload format
|
183
|
+
def rsc_payload_react_component(component_name, options = {})
|
184
|
+
# rsc_payload_react_component doesn't have the prerender option
|
185
|
+
# Because setting prerender to false will not do anything
|
186
|
+
options[:prerender] = true
|
187
|
+
run_stream_inside_fiber do
|
188
|
+
internal_rsc_payload_react_component(component_name, options)
|
142
189
|
end
|
143
|
-
|
144
|
-
@rorp_rendering_fibers << rendering_fiber
|
145
|
-
|
146
|
-
# return the first chunk of the fiber
|
147
|
-
# It contains the initial html of the component
|
148
|
-
# all updates will be appended to the stream sent to browser
|
149
|
-
rendering_fiber.resume
|
150
190
|
end
|
151
191
|
|
152
192
|
# react_component_hash is used to return multiple HTML strings for server rendering, such as for
|
@@ -207,17 +247,19 @@ module ReactOnRails
|
|
207
247
|
# props: Ruby Hash or JSON string which contains the properties to pass to the redux store.
|
208
248
|
# Options
|
209
249
|
# defer: false -- pass as true if you wish to render this below your component.
|
210
|
-
|
250
|
+
# force_load: false -- pass as true if you wish to hydrate this store immediately instead of
|
251
|
+
# waiting for the page to load.
|
252
|
+
def redux_store(store_name, props: {}, defer: false, force_load: nil)
|
253
|
+
force_load = ReactOnRails.configuration.force_load if force_load.nil?
|
211
254
|
redux_store_data = { store_name: store_name,
|
212
|
-
props: props
|
255
|
+
props: props,
|
256
|
+
force_load: force_load }
|
213
257
|
if defer
|
214
|
-
|
215
|
-
@registered_stores_defer_render << redux_store_data
|
258
|
+
registered_stores_defer_render << redux_store_data
|
216
259
|
"YOU SHOULD NOT SEE THIS ON YOUR VIEW -- Uses as a code block, like <% redux_store %> " \
|
217
260
|
"and not <%= redux store %>"
|
218
261
|
else
|
219
|
-
|
220
|
-
@registered_stores << redux_store_data
|
262
|
+
registered_stores << redux_store_data
|
221
263
|
result = render_redux_store_data(redux_store_data)
|
222
264
|
prepend_render_rails_context(result)
|
223
265
|
end
|
@@ -229,9 +271,9 @@ module ReactOnRails
|
|
229
271
|
# client side rendering of this hydration data, which is a hidden div with a matching class
|
230
272
|
# that contains a data props.
|
231
273
|
def redux_store_hydration_data
|
232
|
-
return if
|
274
|
+
return if registered_stores_defer_render.blank?
|
233
275
|
|
234
|
-
|
276
|
+
registered_stores_defer_render.reduce(+"") do |accum, redux_store_data|
|
235
277
|
accum << render_redux_store_data(redux_store_data)
|
236
278
|
end.html_safe
|
237
279
|
end
|
@@ -321,6 +363,7 @@ module ReactOnRails
|
|
321
363
|
# ALERT: Keep in sync with node_package/src/types/index.ts for the properties of RailsContext
|
322
364
|
@rails_context ||= begin
|
323
365
|
result = {
|
366
|
+
componentRegistryTimeout: ReactOnRails.configuration.component_registry_timeout,
|
324
367
|
railsEnv: Rails.env,
|
325
368
|
inMailer: in_mailer?,
|
326
369
|
# Locale settings
|
@@ -330,8 +373,14 @@ module ReactOnRails
|
|
330
373
|
# TODO: v13 just use the version if existing
|
331
374
|
rorPro: ReactOnRails::Utils.react_on_rails_pro?
|
332
375
|
}
|
376
|
+
|
333
377
|
if ReactOnRails::Utils.react_on_rails_pro?
|
334
378
|
result[:rorProVersion] = ReactOnRails::Utils.react_on_rails_pro_version
|
379
|
+
|
380
|
+
if ReactOnRails::Utils.rsc_support_enabled?
|
381
|
+
rsc_payload_url = ReactOnRailsPro.configuration.rsc_payload_generation_url_path
|
382
|
+
result[:rscPayloadGenerationUrlPath] = rsc_payload_url
|
383
|
+
end
|
335
384
|
end
|
336
385
|
|
337
386
|
if defined?(request) && request.present?
|
@@ -379,8 +428,13 @@ module ReactOnRails
|
|
379
428
|
is_component_pack_present = File.exist?(generated_components_pack_path(react_component_name))
|
380
429
|
raise_missing_autoloaded_bundle(react_component_name) unless is_component_pack_present
|
381
430
|
end
|
382
|
-
|
383
|
-
|
431
|
+
|
432
|
+
options = { defer: ReactOnRails.configuration.generated_component_packs_loading_strategy == :defer }
|
433
|
+
# Old versions of Shakapacker don't support async script tags.
|
434
|
+
# ReactOnRails.configure already validates if async loading is supported by the installed Shakapacker version.
|
435
|
+
# Therefore, we only need to pass the async option if the loading strategy is explicitly set to :async
|
436
|
+
options[:async] = true if ReactOnRails.configuration.generated_component_packs_loading_strategy == :async
|
437
|
+
append_javascript_pack_tag("generated/#{react_component_name}", **options)
|
384
438
|
append_stylesheet_pack_tag("generated/#{react_component_name}")
|
385
439
|
end
|
386
440
|
|
@@ -388,8 +442,56 @@ module ReactOnRails
|
|
388
442
|
|
389
443
|
private
|
390
444
|
|
445
|
+
def run_stream_inside_fiber
|
446
|
+
unless ReactOnRails::Utils.react_on_rails_pro?
|
447
|
+
raise ReactOnRails::Error,
|
448
|
+
"You must use React on Rails Pro to use the stream_react_component method."
|
449
|
+
end
|
450
|
+
|
451
|
+
if @rorp_rendering_fibers.nil?
|
452
|
+
raise ReactOnRails::Error,
|
453
|
+
"You must call stream_view_containing_react_components to render the view containing the react component"
|
454
|
+
end
|
455
|
+
|
456
|
+
rendering_fiber = Fiber.new do
|
457
|
+
stream = yield
|
458
|
+
stream.each_chunk do |chunk|
|
459
|
+
Fiber.yield chunk
|
460
|
+
end
|
461
|
+
end
|
462
|
+
|
463
|
+
@rorp_rendering_fibers << rendering_fiber
|
464
|
+
|
465
|
+
# return the first chunk of the fiber
|
466
|
+
# It contains the initial html of the component
|
467
|
+
# all updates will be appended to the stream sent to browser
|
468
|
+
rendering_fiber.resume
|
469
|
+
end
|
470
|
+
|
471
|
+
def registered_stores
|
472
|
+
@registered_stores ||= []
|
473
|
+
end
|
474
|
+
|
475
|
+
def registered_stores_defer_render
|
476
|
+
@registered_stores_defer_render ||= []
|
477
|
+
end
|
478
|
+
|
479
|
+
def registered_stores_including_deferred
|
480
|
+
registered_stores + registered_stores_defer_render
|
481
|
+
end
|
482
|
+
|
483
|
+
def create_render_options(react_component_name, options)
|
484
|
+
# If no store dependencies are passed, default to all registered stores up till now
|
485
|
+
unless options.key?(:store_dependencies)
|
486
|
+
store_dependencies = registered_stores_including_deferred.map { |store| store[:store_name] }
|
487
|
+
options = options.merge(store_dependencies: store_dependencies.presence)
|
488
|
+
end
|
489
|
+
ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
|
490
|
+
options: options)
|
491
|
+
end
|
492
|
+
|
391
493
|
def internal_stream_react_component(component_name, options = {})
|
392
|
-
options = options.merge(
|
494
|
+
options = options.merge(render_mode: :html_streaming)
|
393
495
|
result = internal_react_component(component_name, options)
|
394
496
|
build_react_component_result_for_server_streamed_content(
|
395
497
|
rendered_html_stream: result[:result],
|
@@ -398,6 +500,15 @@ module ReactOnRails
|
|
398
500
|
)
|
399
501
|
end
|
400
502
|
|
503
|
+
def internal_rsc_payload_react_component(react_component_name, options = {})
|
504
|
+
options = options.merge(render_mode: :rsc_payload_streaming)
|
505
|
+
render_options = create_render_options(react_component_name, options)
|
506
|
+
json_stream = server_rendered_react_component(render_options)
|
507
|
+
json_stream.transform do |chunk|
|
508
|
+
"#{chunk.to_json}\n".html_safe
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
401
512
|
def generated_components_pack_path(component_name)
|
402
513
|
"#{ReactOnRails::PackerUtils.packer_source_entry_path}/generated/#{component_name}.js"
|
403
514
|
end
|
@@ -489,14 +600,13 @@ module ReactOnRails
|
|
489
600
|
)
|
490
601
|
end
|
491
602
|
|
492
|
-
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output,
|
603
|
+
def compose_react_component_html_with_spec_and_console(component_specification_tag, rendered_output,
|
604
|
+
console_script)
|
493
605
|
# IMPORTANT: Ensure that we mark string as html_safe to avoid escaping.
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
HTML
|
499
|
-
html_content.strip.html_safe
|
606
|
+
added_html = "#{component_specification_tag}\n#{console_script}".strip
|
607
|
+
added_html = added_html.present? ? "\n#{added_html}" : ""
|
608
|
+
|
609
|
+
"#{rendered_output}#{added_html}".html_safe
|
500
610
|
end
|
501
611
|
|
502
612
|
def rails_context_if_not_already_rendered
|
@@ -514,7 +624,9 @@ module ReactOnRails
|
|
514
624
|
|
515
625
|
# prepend the rails_context if not yet applied
|
516
626
|
def prepend_render_rails_context(render_value)
|
517
|
-
|
627
|
+
rails_context_content = rails_context_if_not_already_rendered
|
628
|
+
rails_context_content = rails_context_content.present? ? "#{rails_context_content}\n" : ""
|
629
|
+
"#{rails_context_content}#{render_value}".html_safe
|
518
630
|
end
|
519
631
|
|
520
632
|
def internal_react_component(react_component_name, options = {})
|
@@ -525,8 +637,7 @@ module ReactOnRails
|
|
525
637
|
# (re-hydrate the data). This enables react rendered on the client to see that the
|
526
638
|
# server has already rendered the HTML.
|
527
639
|
|
528
|
-
render_options =
|
529
|
-
options: options)
|
640
|
+
render_options = create_render_options(react_component_name, options)
|
530
641
|
|
531
642
|
# Setup the page_loaded_js, which is the same regardless of prerendering or not!
|
532
643
|
# The reason is that React is smart about not doing extra work if the server rendering did its job.
|
@@ -534,14 +645,18 @@ module ReactOnRails
|
|
534
645
|
json_safe_and_pretty(render_options.client_props).html_safe,
|
535
646
|
type: "application/json",
|
536
647
|
class: "js-react-on-rails-component",
|
648
|
+
id: "js-react-on-rails-component-#{render_options.dom_id}",
|
537
649
|
"data-component-name" => render_options.react_component_name,
|
538
650
|
"data-trace" => (render_options.trace ? true : nil),
|
539
|
-
"data-dom-id" => render_options.dom_id
|
651
|
+
"data-dom-id" => render_options.dom_id,
|
652
|
+
"data-store-dependencies" => render_options.store_dependencies&.to_json,
|
653
|
+
"data-force-load" => (render_options.force_load ? true : nil),
|
654
|
+
"data-render-request-id" => render_options.render_request_id)
|
540
655
|
|
541
656
|
if render_options.force_load
|
542
657
|
component_specification_tag.concat(
|
543
658
|
content_tag(:script, %(
|
544
|
-
ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}');
|
659
|
+
typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}');
|
545
660
|
).html_safe)
|
546
661
|
)
|
547
662
|
end
|
@@ -558,12 +673,22 @@ ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}');
|
|
558
673
|
end
|
559
674
|
|
560
675
|
def render_redux_store_data(redux_store_data)
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
676
|
+
store_hydration_data = content_tag(:script,
|
677
|
+
json_safe_and_pretty(redux_store_data[:props]).html_safe,
|
678
|
+
type: "application/json",
|
679
|
+
"data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe,
|
680
|
+
"data-force-load" => (redux_store_data[:force_load] ? true : nil))
|
681
|
+
|
682
|
+
if redux_store_data[:force_load]
|
683
|
+
store_hydration_data.concat(
|
684
|
+
content_tag(:script, <<~JS.strip_heredoc.html_safe
|
685
|
+
typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsStoreLoaded('#{redux_store_data[:store_name]}');
|
686
|
+
JS
|
687
|
+
)
|
688
|
+
)
|
689
|
+
end
|
565
690
|
|
566
|
-
prepend_render_rails_context(
|
691
|
+
prepend_render_rails_context(store_hydration_data)
|
567
692
|
end
|
568
693
|
|
569
694
|
def props_string(props)
|
@@ -620,7 +745,7 @@ ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}');
|
|
620
745
|
js_code = ReactOnRails::ServerRenderingJsCode.server_rendering_component_js_code(
|
621
746
|
props_string: props_string(props).gsub("\u2028", '\u2028').gsub("\u2029", '\u2029'),
|
622
747
|
rails_context: rails_context(server_side: true).to_json,
|
623
|
-
redux_stores: initialize_redux_stores,
|
748
|
+
redux_stores: initialize_redux_stores(render_options),
|
624
749
|
react_component_name: react_component_name,
|
625
750
|
render_options: render_options
|
626
751
|
)
|
@@ -636,7 +761,7 @@ ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}');
|
|
636
761
|
js_code: js_code)
|
637
762
|
end
|
638
763
|
|
639
|
-
if render_options.
|
764
|
+
if render_options.streaming?
|
640
765
|
result.transform do |chunk_json_result|
|
641
766
|
if should_raise_streaming_prerender_error?(chunk_json_result, render_options)
|
642
767
|
raise_prerender_error(chunk_json_result, react_component_name, props, js_code)
|
@@ -651,17 +776,20 @@ ReactOnRails.reactOnRailsComponentLoaded('#{render_options.dom_id}');
|
|
651
776
|
result
|
652
777
|
end
|
653
778
|
|
654
|
-
def initialize_redux_stores
|
779
|
+
def initialize_redux_stores(render_options)
|
655
780
|
result = +<<-JS
|
656
781
|
ReactOnRails.clearHydratedStores();
|
657
782
|
JS
|
658
783
|
|
659
|
-
|
784
|
+
store_dependencies = render_options.store_dependencies
|
785
|
+
return result unless store_dependencies.present?
|
660
786
|
|
661
787
|
declarations = +"var reduxProps, store, storeGenerator;\n"
|
662
|
-
|
788
|
+
store_objects = registered_stores_including_deferred.select do |store|
|
789
|
+
store_dependencies.include?(store[:store_name])
|
790
|
+
end
|
663
791
|
|
664
|
-
result <<
|
792
|
+
result << store_objects.each_with_object(declarations) do |redux_store_data, memo|
|
665
793
|
store_name = redux_store_data[:store_name]
|
666
794
|
props = props_string(redux_store_data[:props])
|
667
795
|
memo << <<-JS.strip_heredoc
|
@@ -115,11 +115,17 @@ module ReactOnRails
|
|
115
115
|
translations = {}
|
116
116
|
defaults = {}
|
117
117
|
locale_files.each do |f|
|
118
|
-
|
118
|
+
safe_load_options = ReactOnRails.configuration.i18n_yml_safe_load_options || {}
|
119
|
+
translation = YAML.safe_load(File.open(f), **safe_load_options)
|
119
120
|
key = translation.keys[0]
|
120
121
|
val = flatten(translation[key])
|
121
122
|
translations = translations.deep_merge(key => val)
|
122
123
|
defaults = defaults.deep_merge(flatten_defaults(val)) if key == default_locale
|
124
|
+
rescue Psych::Exception => e
|
125
|
+
raise ReactOnRails::Error, <<~MSG
|
126
|
+
Error parsing #{f}: #{e.message}
|
127
|
+
Consider fixing unsafe YAML or permitting with config.i18n_yml_safe_load_options
|
128
|
+
MSG
|
123
129
|
end
|
124
130
|
[translations.to_json, defaults.to_json]
|
125
131
|
end
|
@@ -45,6 +45,10 @@ module ReactOnRails
|
|
45
45
|
packer.dev_server.running?
|
46
46
|
end
|
47
47
|
|
48
|
+
def self.dev_server_url
|
49
|
+
"#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}"
|
50
|
+
end
|
51
|
+
|
48
52
|
def self.shakapacker_version
|
49
53
|
return @shakapacker_version if defined?(@shakapacker_version)
|
50
54
|
return nil unless ReactOnRails::Utils.gem_available?("shakapacker")
|
@@ -74,16 +78,32 @@ module ReactOnRails
|
|
74
78
|
# the webpack-dev-server is provided by the config value
|
75
79
|
# "same_bundle_for_client_and_server" where a value of true
|
76
80
|
# would mean that the bundle is created by the webpack-dev-server
|
77
|
-
|
81
|
+
is_bundle_running_on_server = (bundle_name == ReactOnRails.configuration.server_bundle_js_file) ||
|
82
|
+
(bundle_name == ReactOnRails.configuration.rsc_bundle_js_file)
|
78
83
|
|
79
|
-
if packer.dev_server.running? && (!
|
84
|
+
if packer.dev_server.running? && (!is_bundle_running_on_server ||
|
80
85
|
ReactOnRails.configuration.same_bundle_for_client_and_server)
|
81
|
-
"#{
|
86
|
+
"#{dev_server_url}#{hashed_bundle_name}"
|
82
87
|
else
|
83
88
|
File.expand_path(File.join("public", hashed_bundle_name)).to_s
|
84
89
|
end
|
85
90
|
end
|
86
91
|
|
92
|
+
def self.public_output_uri_path
|
93
|
+
"#{packer.config.public_output_path.relative_path_from(packer.config.public_path)}/"
|
94
|
+
end
|
95
|
+
|
96
|
+
# The function doesn't ensure that the asset exists.
|
97
|
+
# - It just returns url to the asset if dev server is running
|
98
|
+
# - Otherwise it returns file path to the asset
|
99
|
+
def self.asset_uri_from_packer(asset_name)
|
100
|
+
if dev_server_running?
|
101
|
+
"#{dev_server_url}/#{public_output_uri_path}#{asset_name}"
|
102
|
+
else
|
103
|
+
File.join(packer_public_output_path, asset_name).to_s
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
87
107
|
def self.precompile?
|
88
108
|
return ::Webpacker.config.webpacker_precompile? if using_webpacker_const?
|
89
109
|
return ::Shakapacker.config.shakapacker_precompile? if using_shakapacker_const?
|
@@ -44,11 +44,66 @@ module ReactOnRails
|
|
44
44
|
puts(Rainbow("Generated Packs: #{output_path}").yellow)
|
45
45
|
end
|
46
46
|
|
47
|
+
def first_js_statement_in_code(content) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
48
|
+
return "" if content.nil? || content.empty?
|
49
|
+
|
50
|
+
start_index = 0
|
51
|
+
content_length = content.length
|
52
|
+
|
53
|
+
while start_index < content_length
|
54
|
+
# Skip whitespace
|
55
|
+
start_index += 1 while start_index < content_length && content[start_index].match?(/\s/)
|
56
|
+
|
57
|
+
break if start_index >= content_length
|
58
|
+
|
59
|
+
current_chars = content[start_index, 2]
|
60
|
+
|
61
|
+
case current_chars
|
62
|
+
when "//"
|
63
|
+
# Single-line comment
|
64
|
+
newline_index = content.index("\n", start_index)
|
65
|
+
return "" if newline_index.nil?
|
66
|
+
|
67
|
+
start_index = newline_index + 1
|
68
|
+
when "/*"
|
69
|
+
# Multi-line comment
|
70
|
+
comment_end = content.index("*/", start_index)
|
71
|
+
return "" if comment_end.nil?
|
72
|
+
|
73
|
+
start_index = comment_end + 2
|
74
|
+
else
|
75
|
+
# Found actual content
|
76
|
+
next_line_index = content.index("\n", start_index)
|
77
|
+
return next_line_index ? content[start_index...next_line_index].strip : content[start_index..].strip
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
""
|
82
|
+
end
|
83
|
+
|
84
|
+
def client_entrypoint?(file_path)
|
85
|
+
content = File.read(file_path)
|
86
|
+
# has "use client" directive. It can be "use client" or 'use client'
|
87
|
+
first_js_statement_in_code(content).match?(/^["']use client["'](?:;|\s|$)/)
|
88
|
+
end
|
89
|
+
|
47
90
|
def pack_file_contents(file_path)
|
48
91
|
registered_component_name = component_name(file_path)
|
49
|
-
|
50
|
-
|
51
|
-
|
92
|
+
load_server_components = ReactOnRails::Utils.rsc_support_enabled?
|
93
|
+
|
94
|
+
if load_server_components && !client_entrypoint?(file_path)
|
95
|
+
return <<~FILE_CONTENT.strip
|
96
|
+
import registerServerComponent from 'react-on-rails/registerServerComponent/client';
|
97
|
+
|
98
|
+
registerServerComponent("#{registered_component_name}");
|
99
|
+
FILE_CONTENT
|
100
|
+
end
|
101
|
+
|
102
|
+
relative_component_path = relative_component_path_from_generated_pack(file_path)
|
103
|
+
|
104
|
+
<<~FILE_CONTENT.strip
|
105
|
+
import ReactOnRails from 'react-on-rails/client';
|
106
|
+
import #{registered_component_name} from '#{relative_component_path}';
|
52
107
|
|
53
108
|
ReactOnRails.register({#{registered_component_name}});
|
54
109
|
FILE_CONTENT
|
@@ -61,23 +116,41 @@ module ReactOnRails
|
|
61
116
|
puts(Rainbow("Generated Server Bundle: #{generated_server_bundle_file_path}").orange)
|
62
117
|
end
|
63
118
|
|
119
|
+
def build_server_pack_content(component_on_server_imports, server_components, client_components)
|
120
|
+
content = <<~FILE_CONTENT
|
121
|
+
import ReactOnRails from 'react-on-rails';
|
122
|
+
|
123
|
+
#{component_on_server_imports.join("\n")}\n
|
124
|
+
FILE_CONTENT
|
125
|
+
|
126
|
+
if server_components.any?
|
127
|
+
content += <<~FILE_CONTENT
|
128
|
+
import registerServerComponent from 'react-on-rails/registerServerComponent/server';
|
129
|
+
registerServerComponent({#{server_components.join(",\n")}});\n
|
130
|
+
FILE_CONTENT
|
131
|
+
end
|
132
|
+
|
133
|
+
content + "ReactOnRails.register({#{client_components.join(",\n")}});"
|
134
|
+
end
|
135
|
+
|
64
136
|
def generated_server_pack_file_content
|
65
137
|
common_components_for_server_bundle = common_component_to_path.delete_if { |k| server_component_to_path.key?(k) }
|
66
138
|
component_for_server_registration_to_path = common_components_for_server_bundle.merge(server_component_to_path)
|
67
139
|
|
68
|
-
|
140
|
+
component_on_server_imports = component_for_server_registration_to_path.map do |name, component_path|
|
69
141
|
"import #{name} from '#{relative_path(generated_server_bundle_file_path, component_path)}';"
|
70
142
|
end
|
71
143
|
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
import ReactOnRails from 'react-on-rails';
|
144
|
+
load_server_components = ReactOnRails::Utils.rsc_support_enabled?
|
145
|
+
server_components = component_for_server_registration_to_path.keys.delete_if do |name|
|
146
|
+
next true unless load_server_components
|
76
147
|
|
77
|
-
|
148
|
+
component_path = component_for_server_registration_to_path[name]
|
149
|
+
client_entrypoint?(component_path)
|
150
|
+
end
|
151
|
+
client_components = component_for_server_registration_to_path.keys - server_components
|
78
152
|
|
79
|
-
|
80
|
-
FILE_CONTENT
|
153
|
+
build_server_pack_content(component_on_server_imports, server_components, client_components)
|
81
154
|
end
|
82
155
|
|
83
156
|
def add_generated_pack_to_server_bundle
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "rainbow"
|
4
|
+
|
3
5
|
# rubocop:disable: Layout/IndentHeredoc
|
4
6
|
module ReactOnRails
|
5
7
|
class PrerenderError < ::ReactOnRails::Error
|
@@ -51,11 +53,17 @@ module ReactOnRails
|
|
51
53
|
message << <<~MSG
|
52
54
|
Encountered error:
|
53
55
|
|
54
|
-
#{err}
|
56
|
+
#{err.inspect}
|
55
57
|
|
56
58
|
MSG
|
57
59
|
|
58
|
-
backtrace =
|
60
|
+
backtrace = if Utils.full_text_errors_enabled?
|
61
|
+
err.backtrace.join("\n")
|
62
|
+
else
|
63
|
+
"#{Rails.backtrace_cleaner.clean(err.backtrace).join("\n")}\n" +
|
64
|
+
Rainbow("The rest of the backtrace is hidden. " \
|
65
|
+
"To see the full backtrace, set FULL_TEXT_ERRORS=true.").red
|
66
|
+
end
|
59
67
|
else
|
60
68
|
backtrace = nil
|
61
69
|
end
|
@@ -15,9 +15,17 @@ module ReactOnRails
|
|
15
15
|
def initialize(react_component_name: required("react_component_name"), options: required("options"))
|
16
16
|
@react_component_name = react_component_name.camelize
|
17
17
|
@options = options
|
18
|
+
# The render_request_id serves as a unique identifier for each render request.
|
19
|
+
# We cannot rely solely on dom_id, as it should be unique for each component on the page,
|
20
|
+
# but the server can render the same page multiple times concurrently for different users.
|
21
|
+
# Therefore, we need an additional unique identifier that can be used both on the client and server.
|
22
|
+
# This ID can also be used to associate specific data with a particular rendered component
|
23
|
+
# on either the server or client.
|
24
|
+
# This ID is only present if RSC support is enabled because it's only used in that case.
|
25
|
+
@render_request_id = self.class.generate_request_id if ReactOnRails::Utils.rsc_support_enabled?
|
18
26
|
end
|
19
27
|
|
20
|
-
attr_reader :react_component_name
|
28
|
+
attr_reader :react_component_name, :render_request_id
|
21
29
|
|
22
30
|
def throw_js_errors
|
23
31
|
options.fetch(:throw_js_errors, false)
|
@@ -111,8 +119,36 @@ module ReactOnRails
|
|
111
119
|
options[key] = value
|
112
120
|
end
|
113
121
|
|
114
|
-
def
|
115
|
-
|
122
|
+
def render_mode
|
123
|
+
# Determines the React rendering strategy:
|
124
|
+
# - :sync: Synchronous SSR using renderToString (blocking and rendering in one shot)
|
125
|
+
# - :html_streaming: Progressive SSR using renderToPipeableStream (non-blocking and rendering incrementally)
|
126
|
+
# - :rsc_payload_streaming: Server Components serialized in React flight format
|
127
|
+
# (non-blocking and rendering incrementally).
|
128
|
+
options.fetch(:render_mode, :sync)
|
129
|
+
end
|
130
|
+
|
131
|
+
def streaming?
|
132
|
+
# Returns true if the component should be rendered incrementally
|
133
|
+
%i[html_streaming rsc_payload_streaming].include?(render_mode)
|
134
|
+
end
|
135
|
+
|
136
|
+
def rsc_payload_streaming?
|
137
|
+
# Returns true if the component should be rendered as a React Server Component
|
138
|
+
render_mode == :rsc_payload_streaming
|
139
|
+
end
|
140
|
+
|
141
|
+
def html_streaming?
|
142
|
+
# Returns true if the component should be rendered incrementally
|
143
|
+
render_mode == :html_streaming
|
144
|
+
end
|
145
|
+
|
146
|
+
def store_dependencies
|
147
|
+
options[:store_dependencies]
|
148
|
+
end
|
149
|
+
|
150
|
+
def self.generate_request_id
|
151
|
+
SecureRandom.uuid
|
116
152
|
end
|
117
153
|
|
118
154
|
private
|