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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +479 -81
  3. data/CONTRIBUTING.md +61 -47
  4. data/Gemfile.development_dependencies +1 -1
  5. data/Gemfile.lock +4 -4
  6. data/KUDOS.md +22 -1
  7. data/NEWS.md +48 -48
  8. data/PROJECTS.md +45 -40
  9. data/README.md +24 -16
  10. data/SUMMARY.md +62 -52
  11. data/eslint.config.ts +213 -0
  12. data/knip.ts +100 -0
  13. data/lib/generators/USAGE +1 -1
  14. data/lib/generators/react_on_rails/dev_tests_generator.rb +4 -8
  15. data/lib/generators/react_on_rails/templates/.eslintrc +1 -1
  16. data/lib/generators/react_on_rails/templates/base/base/app/javascript/bundles/HelloWorld/components/HelloWorld.module.css +2 -2
  17. data/lib/generators/react_on_rails/templates/base/base/app/javascript/bundles/HelloWorld/components/HelloWorldServer.js +1 -1
  18. data/lib/generators/react_on_rails/templates/base/base/app/javascript/packs/registration.js.tt +1 -1
  19. data/lib/generators/react_on_rails/templates/base/base/config/shakapacker.yml +1 -1
  20. data/lib/generators/react_on_rails/templates/redux/base/app/javascript/bundles/HelloWorld/reducers/helloWorldReducer.js +1 -1
  21. data/lib/react_on_rails/configuration.rb +81 -12
  22. data/lib/react_on_rails/controller.rb +4 -2
  23. data/lib/react_on_rails/engine.rb +1 -3
  24. data/lib/react_on_rails/helper.rb +184 -56
  25. data/lib/react_on_rails/locales/base.rb +7 -1
  26. data/lib/react_on_rails/packer_utils.rb +23 -3
  27. data/lib/react_on_rails/packs_generator.rb +84 -11
  28. data/lib/react_on_rails/prerender_error.rb +10 -2
  29. data/lib/react_on_rails/react_component/render_options.rb +39 -3
  30. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +6 -2
  31. data/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +4 -2
  32. data/lib/react_on_rails/utils.rb +67 -23
  33. data/lib/react_on_rails/version.rb +1 -1
  34. data/lib/react_on_rails/version_checker.rb +34 -23
  35. data/lib/tasks/assets.rake +1 -1
  36. data/react_on_rails.gemspec +2 -2
  37. data/tsconfig.eslint.json +6 -0
  38. data/tsconfig.json +10 -6
  39. 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 react components.
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
- unless ReactOnRails::Utils.react_on_rails_pro?
128
- raise ReactOnRails::Error,
129
- "You must use React on Rails Pro to use the stream_react_component method."
130
- end
131
-
132
- if @rorp_rendering_fibers.nil?
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
- rendering_fiber = Fiber.new do
138
- stream = internal_stream_react_component(component_name, options)
139
- stream.each_chunk do |chunk|
140
- Fiber.yield chunk
141
- end
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
- def redux_store(store_name, props: {}, defer: false)
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
- @registered_stores_defer_render ||= []
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
- @registered_stores ||= []
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 @registered_stores_defer_render.blank?
274
+ return if registered_stores_defer_render.blank?
233
275
 
234
- @registered_stores_defer_render.reduce(+"") do |accum, redux_store_data|
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
- append_javascript_pack_tag("generated/#{react_component_name}",
383
- defer: ReactOnRails.configuration.defer_generated_component_packs)
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(stream?: true)
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, console_script)
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
- html_content = <<~HTML
495
- #{rendered_output}
496
- #{component_specification_tag}
497
- #{console_script}
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
- "#{rails_context_if_not_already_rendered}\n#{render_value}".strip.html_safe
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 = ReactOnRails::ReactComponent::RenderOptions.new(react_component_name: react_component_name,
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
- result = content_tag(:script,
562
- json_safe_and_pretty(redux_store_data[:props]).html_safe,
563
- type: "application/json",
564
- "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe)
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(result)
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.stream?
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
- return result unless @registered_stores.present? || @registered_stores_defer_render.present?
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
- all_stores = (@registered_stores || []) + (@registered_stores_defer_render || [])
788
+ store_objects = registered_stores_including_deferred.select do |store|
789
+ store_dependencies.include?(store[:store_name])
790
+ end
663
791
 
664
- result << all_stores.each_with_object(declarations) do |redux_store_data, memo|
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
- translation = YAML.safe_load(File.open(f))
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
- is_server_bundle = bundle_name == ReactOnRails.configuration.server_bundle_js_file
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? && (!is_server_bundle ||
84
+ if packer.dev_server.running? && (!is_bundle_running_on_server ||
80
85
  ReactOnRails.configuration.same_bundle_for_client_and_server)
81
- "#{packer.dev_server.protocol}://#{packer.dev_server.host_with_port}#{hashed_bundle_name}"
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
- <<~FILE_CONTENT
50
- import ReactOnRails from 'react-on-rails';
51
- import #{registered_component_name} from '#{relative_component_path_from_generated_pack(file_path)}';
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
- server_component_imports = component_for_server_registration_to_path.map do |name, component_path|
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
- components_to_register = component_for_server_registration_to_path.keys
73
-
74
- <<~FILE_CONTENT
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
- #{server_component_imports.join("\n")}
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
- ReactOnRails.register({#{components_to_register.join(",\n")}});
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 = err.backtrace.first(15).join("\n")
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 stream?
115
- options[:stream?]
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