diff --git a/LICENSE.md b/LICENSE.md index 1d78ca5849..a6ba12b5c4 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -11,7 +11,7 @@ This repository contains code under two different licenses: The following directories and all their contents are licensed under the **MIT License** (see full text below): -- `lib/react_on_rails/` (excluding `lib/react_on_rails/pro/`) +- `lib/react_on_rails/` (entire directory) - `packages/react-on-rails/` (entire package) - All other directories in this repository not explicitly listed as Pro-licensed @@ -19,7 +19,6 @@ The following directories and all their contents are licensed under the **MIT Li The following directories and all their contents are licensed under the **React on Rails Pro License**: -- `lib/react_on_rails/pro/` - `packages/react-on-rails-pro/` (entire package) - `react_on_rails_pro/` (entire directory) diff --git a/docs/MONOREPO_MERGER_PLAN.md b/docs/MONOREPO_MERGER_PLAN.md index ac4bf0054c..16979f45b4 100644 --- a/docs/MONOREPO_MERGER_PLAN.md +++ b/docs/MONOREPO_MERGER_PLAN.md @@ -404,7 +404,74 @@ After the initial merge, the following CI adjustments may be needed: --- -#### PR #5: Add Pro Node Renderer Package +#### PR #5: Move Pro Features from Core Gem to Pro Gem + +**Branch:** `move-pro-features-to-pro-gem` + +**Objectives:** + +- Move all Pro features from `lib/react_on_rails/pro/` to Pro gem +- Delete `lib/react_on_rails/pro/` directory entirely +- Ensure core gem is 100% MIT licensed with zero Pro code + +**Tasks:** + +- [x] Move `immediate_hydration` config from core gem to Pro gem (default: true in Pro) +- [x] Refactor `RenderOptions` to remove Pro utilities +- [x] Refactor helper methods (`generate_component_script`, `generate_store_script`) to use data enhancement pattern +- [x] Create Pro helper module in Pro gem with enhancement methods +- [x] Delete `lib/react_on_rails/pro/` directory entirely +- [x] Update LICENSE.md to remove `lib/react_on_rails/pro/` reference +- [x] Update tests in both gems + +**Implementation Details:** + +The `immediate_hydration` feature was the only Pro feature in the core gem. The refactoring uses a data enhancement pattern: + +1. Core gem collects script attributes/content as data structures (not HTML) +2. If Pro gem loaded, it modifies the data (adds attributes, adds extra scripts) +3. Core gem generates final HTML from the (possibly enhanced) data + +**Benefits:** + +- ✅ Clean separation: Core gem = 100% MIT, Pro gem = 100% Pro license +- ✅ No HTML parsing needed +- ✅ No Pro warning badge needed (can't enable Pro features without Pro gem) +- ✅ Better architecture: Core gem doesn't know about Pro internals + +**License Compliance:** + +- [x] **CRITICAL: Update LICENSE.md:** + + ```md + ## MIT License applies to: + + - `lib/react_on_rails/` (entire directory) + - `packages/react-on-rails/` (entire package) + + ## React on Rails Pro License applies to: + + - `packages/react-on-rails-pro/` (entire package) + - `react_on_rails_pro/` (entire directory) + ``` + +- [x] Verify no Pro code remains in core gem directories + +**Success Criteria:** ✅ All CI checks pass + `lib/react_on_rails/pro/` deleted + Core gem is 100% MIT licensed + +**Estimated Duration:** 2-3 days + +**Risk Level:** Medium (requires careful refactoring of helper methods) + +**Developer Notes:** + +- The core gem now calls `ReactOnRailsPro::Helper.enhance_component_script_data` and `ReactOnRailsPro::Helper.enhance_store_script_data` if Pro gem is loaded +- The `immediate_hydration` method in `RenderOptions` now uses `retrieve_react_on_rails_pro_config_value_for(:immediate_hydration)` +- Tests have been updated to mock Pro gem functionality + +--- + +#### PR #6: Add Pro Node Renderer Package **Branch:** `add-pro-node-renderer` @@ -455,7 +522,7 @@ After the initial merge, the following CI adjustments may be needed: ### Phase 6: Final Monorepo Restructuring -#### PR #6: Restructure Ruby Gems to Final Layout +#### PR #7: Restructure Ruby Gems to Final Layout **Branch:** `restructure-ruby-gems` @@ -522,7 +589,7 @@ After the initial merge, the following CI adjustments may be needed: ### Phase 7: CI/CD & Tooling Unification -#### PR #7: Unify CI/CD Configuration +#### PR #8: Unify CI/CD Configuration **Branch:** `unify-cicd` @@ -588,7 +655,7 @@ After the initial merge, the following CI adjustments may be needed: ### Phase 8: Documentation & Polish -#### PR #8: Update Documentation & Examples +#### PR #9: Update Documentation & Examples **Branch:** `update-docs-examples` diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 3ef7332d2c..2338d763dc 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -46,8 +46,6 @@ def self.configuration components_subdirectory: nil, make_generated_server_bundle_the_entrypoint: false, defer_generated_component_packs: false, - # React on Rails Pro (licensed) feature - enables immediate hydration of React components - immediate_hydration: false, # Maximum time in milliseconds to wait for client-side component registration after page load. # If exceeded, an error will be thrown for server-side rendered components not registered on the client. # Set to 0 to disable the timeout and wait indefinitely for component registration. @@ -72,7 +70,7 @@ class Configuration :server_render_method, :random_dom_id, :auto_load_bundle, :same_bundle_for_client_and_server, :rendering_props_extension, :make_generated_server_bundle_the_entrypoint, - :generated_component_packs_loading_strategy, :immediate_hydration, :rsc_bundle_js_file, + :generated_component_packs_loading_strategy, :rsc_bundle_js_file, :react_client_manifest_file, :react_server_client_manifest_file, :component_registry_timeout, :server_bundle_output_path, :enforce_private_server_bundles @@ -89,7 +87,7 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender same_bundle_for_client_and_server: nil, i18n_dir: nil, i18n_yml_dir: nil, i18n_output_format: nil, i18n_yml_safe_load_options: nil, random_dom_id: nil, server_render_method: nil, rendering_props_extension: nil, - components_subdirectory: nil, auto_load_bundle: nil, immediate_hydration: nil, + components_subdirectory: nil, auto_load_bundle: nil, rsc_bundle_js_file: nil, react_client_manifest_file: nil, react_server_client_manifest_file: nil, component_registry_timeout: nil, server_bundle_output_path: nil, enforce_private_server_bundles: nil) self.node_modules_location = node_modules_location.present? ? node_modules_location : Rails.root @@ -134,7 +132,6 @@ def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender self.auto_load_bundle = auto_load_bundle self.make_generated_server_bundle_the_entrypoint = make_generated_server_bundle_the_entrypoint self.defer_generated_component_packs = defer_generated_component_packs - self.immediate_hydration = immediate_hydration self.generated_component_packs_loading_strategy = generated_component_packs_loading_strategy self.server_bundle_output_path = server_bundle_output_path self.enforce_private_server_bundles = enforce_private_server_bundles diff --git a/lib/react_on_rails/controller.rb b/lib/react_on_rails/controller.rb index ae254b1a5a..6fb82fb5ea 100644 --- a/lib/react_on_rails/controller.rb +++ b/lib/react_on_rails/controller.rb @@ -15,7 +15,10 @@ module Controller # Be sure to include view helper `redux_store_hydration_data` at the end of your layout or view # or else there will be no client side hydration of your stores. def redux_store(store_name, props: {}, immediate_hydration: nil) - immediate_hydration = ReactOnRails.configuration.immediate_hydration if immediate_hydration.nil? + if immediate_hydration.nil? && ReactOnRails::Utils.react_on_rails_pro? + immediate_hydration = ReactOnRailsPro.configuration.immediate_hydration + end + immediate_hydration = false if immediate_hydration.nil? redux_store_data = { store_name: store_name, props: props, immediate_hydration: immediate_hydration } diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index da6dd8995b..b60a46d906 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -11,12 +11,10 @@ require "react_on_rails/utils" require "react_on_rails/json_output" require "active_support/concern" -require "react_on_rails/pro/helper" module ReactOnRails module Helper include ReactOnRails::Utils::Required - include ReactOnRails::Pro::Helper COMPONENT_HTML_KEY = "componentHtml" @@ -255,7 +253,10 @@ def react_component_hash(component_name, options = {}) # immediate_hydration: false -- React on Rails Pro (licensed) feature. Pass as true if you wish to # hydrate this store immediately instead of waiting for the page to load. def redux_store(store_name, props: {}, defer: false, immediate_hydration: nil) - immediate_hydration = ReactOnRails.configuration.immediate_hydration if immediate_hydration.nil? + if immediate_hydration.nil? && ReactOnRails::Utils.react_on_rails_pro? + immediate_hydration = ReactOnRailsPro.configuration.immediate_hydration + end + immediate_hydration = false if immediate_hydration.nil? redux_store_data = { store_name: store_name, props: props, @@ -793,6 +794,68 @@ def in_mailer? instrument_method :react_component_hash, type: "ReactOnRails", name: "react_component_hash" end + # Generates the complete component specification script tag. + # Handles both immediate hydration (Pro feature) and standard cases. + def generate_component_script(render_options) + # Collect script data + script_attrs = { + type: "application/json", + class: "js-react-on-rails-component", + id: "js-react-on-rails-component-#{render_options.dom_id}", + "data-component-name" => render_options.react_component_name, + "data-trace" => (render_options.trace ? true : nil), + "data-dom-id" => render_options.dom_id, + "data-store-dependencies" => render_options.store_dependencies&.to_json + } + + script_content = json_safe_and_pretty(render_options.client_props).html_safe + additional_scripts = [] + + # Let Pro gem enhance if available + if ReactOnRails::Utils.react_on_rails_pro? + result = ReactOnRailsPro::Helper.enhance_component_script_data( + script_attrs: script_attrs, + script_content: script_content, + render_options: render_options + ) + script_attrs = result[:script_attrs] + script_content = result[:script_content] + additional_scripts = result[:additional_scripts] + end + + # Generate final HTML + main_script = content_tag(:script, script_content, script_attrs) + ([main_script] + additional_scripts).join("\n").html_safe + end + + # Generates the complete store hydration script tag. + # Handles both immediate hydration (Pro feature) and standard cases. + def generate_store_script(redux_store_data) + script_attrs = { + type: "application/json", + "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe + } + + script_content = json_safe_and_pretty(redux_store_data[:props]).html_safe + additional_scripts = [] + + # Let Pro gem enhance if available + if ReactOnRails::Utils.react_on_rails_pro? + result = ReactOnRailsPro::Helper.enhance_store_script_data( + script_attrs: script_attrs, + script_content: script_content, + redux_store_data: redux_store_data + ) + script_attrs = result[:script_attrs] + script_content = result[:script_content] + additional_scripts = result[:additional_scripts] + end + + # Generate final HTML + main_script = content_tag(:script, script_content, script_attrs) + ([main_script] + additional_scripts).join("\n").html_safe + end + def raise_missing_autoloaded_bundle(react_component_name) msg = <<~MSG **ERROR** ReactOnRails: Component "#{react_component_name}" is configured as "auto_load_bundle: true" diff --git a/lib/react_on_rails/pro/NOTICE b/lib/react_on_rails/pro/NOTICE deleted file mode 100644 index 2c0b1bb4cd..0000000000 --- a/lib/react_on_rails/pro/NOTICE +++ /dev/null @@ -1,21 +0,0 @@ -# React on Rails Pro License - -The files in this directory and its subdirectories are licensed under the **React on Rails Pro** license, which is separate from the MIT license that covers the core React on Rails functionality. - -## License Terms - -These files are proprietary software and are **NOT** covered by the MIT license found in the root LICENSE.md file. Usage requires a valid React on Rails Pro license. - -## Distribution - -Files in this directory will be **omitted** from future distributions of the open source React on Rails Ruby gem. They are exclusively available to React on Rails Pro licensees. - -## License Reference - -For the complete React on Rails Pro license terms, see: `REACT-ON-RAILS-PRO-LICENSE.md` in the root directory of this repository. - -## More Information - -For React on Rails Pro licensing information and to obtain a license, please visit: -- [React on Rails Pro](https://www.shakacode.com/react-on-rails-pro/) -- Contact: [react_on_rails@shakacode.com](mailto:react_on_rails@shakacode.com) \ No newline at end of file diff --git a/lib/react_on_rails/pro/helper.rb b/lib/react_on_rails/pro/helper.rb deleted file mode 100644 index 54db997f03..0000000000 --- a/lib/react_on_rails/pro/helper.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -# /* -# * Copyright (c) 2025 Shakacode LLC -# * -# * This file is NOT licensed under the MIT (open source) license. -# * It is part of the React on Rails Pro offering and is licensed separately. -# * -# * Unauthorized copying, modification, distribution, or use of this file, -# * via any medium, is strictly prohibited without a valid license agreement -# * from Shakacode LLC. -# * -# * For licensing terms, please see: -# * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md -# */ - -module ReactOnRails - module Pro - module Helper - IMMEDIATE_HYDRATION_PRO_WARNING = "[REACT ON RAILS] The 'immediate_hydration' feature requires a " \ - "React on Rails Pro license. " \ - "Please visit https://shakacode.com/react-on-rails-pro to learn more." - - # Generates the complete component specification script tag. - # Handles both immediate hydration (Pro feature) and standard cases. - def generate_component_script(render_options) - # Setup the page_loaded_js, which is the same regardless of prerendering or not! - # The reason is that React is smart about not doing extra work if the server rendering did its job. - component_specification_tag = content_tag(:script, - json_safe_and_pretty(render_options.client_props).html_safe, - type: "application/json", - class: "js-react-on-rails-component", - id: "js-react-on-rails-component-#{render_options.dom_id}", - "data-component-name" => render_options.react_component_name, - "data-trace" => (render_options.trace ? true : nil), - "data-dom-id" => render_options.dom_id, - "data-store-dependencies" => - render_options.store_dependencies&.to_json, - "data-immediate-hydration" => - (render_options.immediate_hydration ? true : nil)) - - # Add immediate invocation script if immediate hydration is enabled - spec_tag = if render_options.immediate_hydration - # Escape dom_id for JavaScript context - escaped_dom_id = escape_javascript(render_options.dom_id) - immediate_script = content_tag(:script, %( - typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsComponentLoaded('#{escaped_dom_id}'); - ).html_safe) - "#{component_specification_tag}\n#{immediate_script}" - else - component_specification_tag - end - - pro_warning_badge = pro_warning_badge_if_needed(render_options.explicitly_disabled_pro_options) - "#{pro_warning_badge}\n#{spec_tag}".html_safe - end - - # Generates the complete store hydration script tag. - # Handles both immediate hydration (Pro feature) and standard cases. - def generate_store_script(redux_store_data) - pro_options_check_result = ReactOnRails::Pro::Utils.disable_pro_render_options_if_not_licensed(redux_store_data) - redux_store_data = pro_options_check_result[:raw_options] - explicitly_disabled_pro_options = pro_options_check_result[:explicitly_disabled_pro_options] - - store_hydration_data = content_tag(:script, - json_safe_and_pretty(redux_store_data[:props]).html_safe, - type: "application/json", - "data-js-react-on-rails-store" => redux_store_data[:store_name].html_safe, - "data-immediate-hydration" => - (redux_store_data[:immediate_hydration] ? true : nil)) - - # Add immediate invocation script if immediate hydration is enabled and Pro license is valid - store_hydration_scripts = if redux_store_data[:immediate_hydration] - # Escape store_name for JavaScript context - escaped_store_name = escape_javascript(redux_store_data[:store_name]) - immediate_script = content_tag(:script, <<~JS.strip_heredoc.html_safe - typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsStoreLoaded('#{escaped_store_name}'); - JS - ) - "#{store_hydration_data}\n#{immediate_script}" - else - store_hydration_data - end - - pro_warning_badge = pro_warning_badge_if_needed(explicitly_disabled_pro_options) - "#{pro_warning_badge}\n#{store_hydration_scripts}".html_safe - end - - def pro_warning_badge_if_needed(explicitly_disabled_pro_options) - return "" unless explicitly_disabled_pro_options.any? - - disabled_features_message = disabled_pro_features_message(explicitly_disabled_pro_options) - warning_message = "[REACT ON RAILS] #{disabled_features_message}\n" \ - "Please visit https://shakacode.com/react-on-rails-pro to learn more." - puts warning_message - Rails.logger.warn warning_message - - tooltip_text = "#{disabled_features_message} Click to learn more." - - <<~HTML.strip - -
-
- React On Rails Pro Required -
-
-
- HTML - end - - def disabled_pro_features_message(explicitly_disabled_pro_options) - return "".html_safe unless explicitly_disabled_pro_options.any? - - feature_list = explicitly_disabled_pro_options.join(", ") - feature_word = explicitly_disabled_pro_options.size == 1 ? "feature" : "features" - "The '#{feature_list}' #{feature_word} " \ - "#{explicitly_disabled_pro_options.size == 1 ? 'requires' : 'require'} a " \ - "React on Rails Pro license. " - end - end - end -end diff --git a/lib/react_on_rails/pro/utils.rb b/lib/react_on_rails/pro/utils.rb deleted file mode 100644 index e98a4c27e8..0000000000 --- a/lib/react_on_rails/pro/utils.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -# /* -# * Copyright (c) 2025 Shakacode LLC -# * -# * This file is NOT licensed under the MIT (open source) license. -# * It is part of the React on Rails Pro offering and is licensed separately. -# * -# * Unauthorized copying, modification, distribution, or use of this file, -# * via any medium, is strictly prohibited without a valid license agreement -# * from Shakacode LLC. -# * -# * For licensing terms, please see: -# * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md -# */ - -module ReactOnRails - module Pro - module Utils - PRO_ONLY_OPTIONS = %i[immediate_hydration].freeze - - # Checks if React on Rails Pro features are available - # @return [Boolean] true if Pro license is valid, false otherwise - def self.support_pro_features? - ReactOnRails::Utils.react_on_rails_pro_licence_valid? - end - - def self.disable_pro_render_options_if_not_licensed(raw_options) - if support_pro_features? - return { - raw_options: raw_options, - explicitly_disabled_pro_options: [] - } - end - - raw_options_after_disable = raw_options.dup - - explicitly_disabled_pro_options = PRO_ONLY_OPTIONS.select do |option| - # Use global configuration if it's not overridden in the options - next ReactOnRails.configuration.send(option) if raw_options[option].nil? - - raw_options[option] - end - explicitly_disabled_pro_options.each { |option| raw_options_after_disable[option] = false } - - { - raw_options: raw_options_after_disable, - explicitly_disabled_pro_options: explicitly_disabled_pro_options - } - end - end - end -end diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 9c1a8a3986..2480263a27 100644 --- a/lib/react_on_rails/react_component/render_options.rb +++ b/lib/react_on_rails/react_component/render_options.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "react_on_rails/utils" -require "react_on_rails/pro/utils" module ReactOnRails module ReactComponent @@ -15,13 +14,10 @@ class RenderOptions # TODO: remove the required for named params def initialize(react_component_name: required("react_component_name"), options: required("options")) @react_component_name = react_component_name.camelize - - result = ReactOnRails::Pro::Utils.disable_pro_render_options_if_not_licensed(options) - @options = result[:raw_options] - @explicitly_disabled_pro_options = result[:explicitly_disabled_pro_options] + @options = options end - attr_reader :react_component_name, :explicitly_disabled_pro_options + attr_reader :react_component_name def throw_js_errors options.fetch(:throw_js_errors, false) @@ -100,7 +96,7 @@ def logging_on_server end def immediate_hydration - retrieve_configuration_value_for(:immediate_hydration) + retrieve_react_on_rails_pro_config_value_for(:immediate_hydration) end def to_s diff --git a/react_on_rails_pro/lib/react_on_rails_pro.rb b/react_on_rails_pro/lib/react_on_rails_pro.rb index 98399d7da5..9bce728920 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro.rb @@ -9,6 +9,7 @@ require "react_on_rails_pro/error" require "react_on_rails_pro/utils" require "react_on_rails_pro/configuration" +require "react_on_rails_pro/helper" require "react_on_rails_pro/cache" require "react_on_rails_pro/stream_cache" require "react_on_rails_pro/server_rendering_pool/pro_rendering" diff --git a/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb b/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb index 3f4b541fe3..c83d31f8b1 100644 --- a/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb +++ b/react_on_rails_pro/lib/react_on_rails_pro/configuration.rb @@ -29,7 +29,8 @@ def self.configuration profile_server_rendering_js_code: Configuration::DEFAULT_PROFILE_SERVER_RENDERING_JS_CODE, raise_non_shell_server_rendering_errors: Configuration::DEFAULT_RAISE_NON_SHELL_SERVER_RENDERING_ERRORS, enable_rsc_support: Configuration::DEFAULT_ENABLE_RSC_SUPPORT, - rsc_payload_generation_url_path: Configuration::DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH + rsc_payload_generation_url_path: Configuration::DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH, + immediate_hydration: Configuration::DEFAULT_IMMEDIATE_HYDRATION ) end @@ -53,6 +54,7 @@ class Configuration # rubocop:disable Metrics/ClassLength DEFAULT_RAISE_NON_SHELL_SERVER_RENDERING_ERRORS = false DEFAULT_ENABLE_RSC_SUPPORT = false DEFAULT_RSC_PAYLOAD_GENERATION_URL_PATH = "rsc_payload/" + DEFAULT_IMMEDIATE_HYDRATION = true attr_accessor :renderer_url, :renderer_password, :tracing, :server_renderer, :renderer_use_fallback_exec_js, :prerender_caching, @@ -61,7 +63,7 @@ class Configuration # rubocop:disable Metrics/ClassLength :remote_bundle_cache_adapter, :ssr_pre_hook_js, :assets_to_copy, :renderer_request_retry_limit, :throw_js_errors, :ssr_timeout, :profile_server_rendering_js_code, :raise_non_shell_server_rendering_errors, :enable_rsc_support, - :rsc_payload_generation_url_path + :rsc_payload_generation_url_path, :immediate_hydration def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, # rubocop:disable Metrics/AbcSize renderer_use_fallback_exec_js: nil, prerender_caching: nil, @@ -71,7 +73,7 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, remote_bundle_cache_adapter: nil, ssr_pre_hook_js: nil, assets_to_copy: nil, renderer_request_retry_limit: nil, throw_js_errors: nil, ssr_timeout: nil, profile_server_rendering_js_code: nil, raise_non_shell_server_rendering_errors: nil, - enable_rsc_support: nil, rsc_payload_generation_url_path: nil) + enable_rsc_support: nil, rsc_payload_generation_url_path: nil, immediate_hydration: nil) self.renderer_url = renderer_url self.renderer_password = renderer_password self.server_renderer = server_renderer @@ -94,6 +96,7 @@ def initialize(renderer_url: nil, renderer_password: nil, server_renderer: nil, self.raise_non_shell_server_rendering_errors = raise_non_shell_server_rendering_errors self.enable_rsc_support = enable_rsc_support self.rsc_payload_generation_url_path = rsc_payload_generation_url_path + self.immediate_hydration = immediate_hydration end def setup_config_values diff --git a/react_on_rails_pro/lib/react_on_rails_pro/helper.rb b/react_on_rails_pro/lib/react_on_rails_pro/helper.rb new file mode 100644 index 0000000000..ae10fd3fdb --- /dev/null +++ b/react_on_rails_pro/lib/react_on_rails_pro/helper.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# /* +# * Copyright (c) 2025 Shakacode LLC +# * +# * This file is NOT licensed under the MIT (open source) license. +# * It is part of the React on Rails Pro offering and is licensed separately. +# * +# * Unauthorized copying, modification, distribution, or use of this file, +# * via any medium, is strictly prohibited without a valid license agreement +# * from Shakacode LLC. +# * +# * For licensing terms, please see: +# * https://github.com/shakacode/react_on_rails/blob/master/REACT-ON-RAILS-PRO-LICENSE.md +# */ + +require "action_view" + +module ReactOnRailsPro + module Helper + extend ActionView::Helpers::TagHelper + extend ActionView::Helpers::JavaScriptHelper + + # Enhances component script data with immediate hydration support + # @param script_attrs [Hash] Base script tag attributes + # @param script_content [String] Script content + # @param render_options [ReactOnRails::ReactComponent::RenderOptions] Render options + # @return [Hash] Enhanced script attributes, script content, and additional scripts + def self.enhance_component_script_data(script_attrs:, script_content:, render_options:) + # NOTE: Currently returns script_content unchanged, but this allows for future + # modifications to the script content if needed (e.g., wrapping, transforming, etc.) + + if render_options.immediate_hydration + # Add data attribute for immediate hydration + script_attrs["data-immediate-hydration"] = true + + # Add immediate invocation script + escaped_dom_id = escape_javascript(render_options.dom_id) + immediate_script = content_tag(:script, %( + typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsComponentLoaded('#{escaped_dom_id}'); + ).html_safe) + + return { + script_attrs: script_attrs, + script_content: script_content, + additional_scripts: [immediate_script] + } + end + + { script_attrs: script_attrs, script_content: script_content, additional_scripts: [] } + end + + # Enhances store script data with immediate hydration support + # @param script_attrs [Hash] Base script tag attributes + # @param script_content [String] Script content + # @param redux_store_data [Hash] Redux store data including store_name and props + # @return [Hash] Enhanced script attributes, script content, and additional scripts + def self.enhance_store_script_data(script_attrs:, script_content:, redux_store_data:) + # NOTE: Currently returns script_content unchanged, but this allows for future + # modifications to the script content if needed (e.g., wrapping, transforming, etc.) + + if redux_store_data[:immediate_hydration] + # Add data attribute for immediate hydration + script_attrs["data-immediate-hydration"] = true + + # Add immediate invocation script + escaped_store_name = escape_javascript(redux_store_data[:store_name]) + immediate_script = content_tag(:script, %( + typeof ReactOnRails === 'object' && ReactOnRails.reactOnRailsStoreLoaded('#{escaped_store_name}'); + ).html_safe) + + return { + script_attrs: script_attrs, + script_content: script_content, + additional_scripts: [immediate_script] + } + end + + { script_attrs: script_attrs, script_content: script_content, additional_scripts: [] } + end + end +end diff --git a/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails_pro.rb b/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails_pro.rb index 1bd2a24727..ff2676c37d 100644 --- a/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails_pro.rb +++ b/react_on_rails_pro/spec/dummy/config/initializers/react_on_rails_pro.rb @@ -39,6 +39,9 @@ # include any files used to generate the JSON props. config.dependency_globs = [File.join(Rails.root, "app", "views", "**", "*.jbuilder")] + # Enable immediate hydration (Pro default is true, explicitly set for clarity) + config.immediate_hydration = true + # When using the Node Renderer, you may require some extra assets in addition to the bundle. # The assets_to_copy option allows the Node Renderer to have assets copied at the end of # the assets:precompile task or directly by the diff --git a/react_on_rails_pro/spec/execjs-compatible-dummy/config/initializers/react_on_rails_pro.rb b/react_on_rails_pro/spec/execjs-compatible-dummy/config/initializers/react_on_rails_pro.rb index 655856f19d..8ebd95ce36 100644 --- a/react_on_rails_pro/spec/execjs-compatible-dummy/config/initializers/react_on_rails_pro.rb +++ b/react_on_rails_pro/spec/execjs-compatible-dummy/config/initializers/react_on_rails_pro.rb @@ -27,4 +27,7 @@ # You can run rake react_on_rails_pro:process_v8_logs to process these files and generate a profile.v8log.json file # which can be analyzed using tools like Speed Scope (https://www.speedscope.app) or Chrome Developer Tools config.profile_server_rendering_js_code = true + + # Enable immediate hydration (Pro default is true, explicitly set for clarity) + config.immediate_hydration = true end diff --git a/spec/dummy/config/initializers/react_on_rails.rb b/spec/dummy/config/initializers/react_on_rails.rb index 54c2f40d5c..83598f028a 100644 --- a/spec/dummy/config/initializers/react_on_rails.rb +++ b/spec/dummy/config/initializers/react_on_rails.rb @@ -41,6 +41,5 @@ def self.adjust_props_for_client_side_hydration(component_name, props) config.rendering_props_extension = RenderingPropsExtension config.components_subdirectory = "startup" config.auto_load_bundle = true - config.immediate_hydration = false config.generated_component_packs_loading_strategy = :defer end diff --git a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb index 3b6608a21b..6e9ad3e16f 100644 --- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb @@ -22,18 +22,52 @@ class PlainReactOnRailsHelper } allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true + react_on_rails_pro_licence_valid?: true, + react_on_rails_pro?: true ) - # Configure immediate_hydration to true for tests since they expect that behavior - ReactOnRails.configure do |config| - config.immediate_hydration = true - end - end + # Mock Pro gem configuration and helper for immediate_hydration + # Also need to mock Gem.loaded_specs for version + gem_spec = Struct.new(:version).new(Gem::Version.new("1.0.0")) + allow(Gem).to receive(:loaded_specs).and_return({ "react_on_rails_pro" => gem_spec }) + + stub_const("ReactOnRailsPro", Module.new do + def self.configuration + @configuration ||= Struct.new(:immediate_hydration).new(true) + end + end) + + stub_const("ReactOnRailsPro::Helper", Module.new do + def self.enhance_component_script_data(args) + if args[:render_options].immediate_hydration + dom_id = args[:render_options].dom_id + script_tag = "" + { + script_attrs: args[:script_attrs].merge("data-immediate-hydration" => true), + script_content: args[:script_content], + additional_scripts: [script_tag] + } + else + { script_attrs: args[:script_attrs], script_content: args[:script_content], additional_scripts: [] } + end + end - after do - # Reset to default - avoid validation issues by setting directly - ReactOnRails.configuration.immediate_hydration = false + def self.enhance_store_script_data(args) + if args[:redux_store_data][:immediate_hydration] + store_name = args[:redux_store_data][:store_name] + script_tag = "" + { + script_attrs: args[:script_attrs].merge("data-immediate-hydration" => true), + script_content: args[:script_content], + additional_scripts: [script_tag] + } + else + { script_attrs: args[:script_attrs], script_content: args[:script_content], additional_scripts: [] } + end + end + end) end let(:hash) do @@ -377,78 +411,6 @@ def helper.append_javascript_pack_tag(name, **options) it { is_expected.to include immediate_hydration_script } end end - - describe "with Pro license warning" do - let(:badge_html_string) { "React On Rails Pro Required" } - - before do - allow(Rails.logger).to receive(:warn) - end - - context "when Pro license is NOT installed and immediate_hydration is true" do - subject(:react_app) { react_component("App", props: props, immediate_hydration: true) } - - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) - end - - it { is_expected.to include(badge_html_string) } - - it "logs a warning" do - react_app - expect(Rails.logger).to have_received(:warn) - .with(a_string_matching(/The 'immediate_hydration' feature requires/)) - end - end - - context "when Pro license is NOT installed and global immediate_hydration is true" do - subject(:react_app) { react_component("App", props: props) } - - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) - end - - around do |example| - ReactOnRails.configure { |config| config.immediate_hydration = true } - example.run - ReactOnRails.configure { |config| config.immediate_hydration = false } - end - - it { is_expected.to include(badge_html_string) } - end - - context "when Pro license is NOT installed and immediate_hydration is false" do - subject(:react_app) { react_component("App", props: props, immediate_hydration: false) } - - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) - end - - it { is_expected.not_to include(badge_html_string) } - - it "does not log a warning" do - react_app - expect(Rails.logger).not_to have_received(:warn) - end - end - - context "when Pro license IS installed and immediate_hydration is true" do - subject(:react_app) { react_component("App", props: props, immediate_hydration: true) } - - before do - allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true - ) - end - - it { is_expected.not_to include(badge_html_string) } - - it "does not log a warning" do - react_app - expect(Rails.logger).not_to have_received(:warn) - end - end - end end describe "#react_component_hash" do @@ -471,40 +433,6 @@ def helper.append_javascript_pack_tag(name, **options) expect(react_app).to have_key("componentHtml") expect(react_app).to have_key("title") end - - context "with Pro license warning" do - let(:badge_html_string) { "React On Rails Pro Required" } - - before do - allow(Rails.logger).to receive(:warn) - end - - context "when Pro license is NOT installed and immediate_hydration is true" do - subject(:react_app) { react_component_hash("App", props: props, immediate_hydration: true) } - - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) - end - - it "adds badge to componentHtml" do - expect(react_app["componentHtml"]).to include(badge_html_string) - end - end - - context "when Pro license IS installed and immediate_hydration is true" do - subject(:react_app) { react_component_hash("App", props: props, immediate_hydration: true) } - - before do - allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true - ) - end - - it "does not add badge to componentHtml" do - expect(react_app["componentHtml"]).not_to include(badge_html_string) - end - end - end end describe "#redux_store" do @@ -529,52 +457,6 @@ def helper.append_javascript_pack_tag(name, **options) it { expect(expect(store).target).to script_tag_be_included(react_store_script) } - - context "with Pro license warning" do - let(:badge_html_string) { "React On Rails Pro Required" } - - before do - allow(Rails.logger).to receive(:warn) - end - - context "when Pro license is NOT installed and immediate_hydration is true" do - subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: true) } - - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) - end - - it { is_expected.to include(badge_html_string) } - - it "logs a warning" do - store - expect(Rails.logger).to have_received(:warn) - .with(a_string_matching(/The 'immediate_hydration' feature requires/)) - end - end - - context "when Pro license is NOT installed and immediate_hydration is false" do - subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: false) } - - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(false) - end - - it { is_expected.not_to include(badge_html_string) } - end - - context "when Pro license IS installed and immediate_hydration is true" do - subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: true) } - - before do - allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro_licence_valid?: true - ) - end - - it { is_expected.not_to include(badge_html_string) } - end - end end describe "#server_render_js", :js, type: :system do diff --git a/spec/dummy/spec/system/integration_spec.rb b/spec/dummy/spec/system/integration_spec.rb index b238fe302e..91f9edbca6 100644 --- a/spec/dummy/spec/system/integration_spec.rb +++ b/spec/dummy/spec/system/integration_spec.rb @@ -88,13 +88,52 @@ def finished_all_ajax_requests? shared_context "with pro features and immediate hydration" do before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro_licence_valid?).and_return(true) - end + allow(ReactOnRails::Utils).to receive_messages( + react_on_rails_pro_licence_valid?: true, + react_on_rails_pro?: true + ) + + # Mock Pro gem for immediate_hydration + pro_config = Struct.new(:immediate_hydration).new(true) + stub_const("ReactOnRailsPro", Module.new do + define_singleton_method(:configuration) { pro_config } + end) + + stub_const("ReactOnRailsPro::Helper", Module.new do + def self.enhance_component_script_data(args) + if args[:render_options].immediate_hydration + dom_id = args[:render_options].dom_id + script_tag = "" + { + script_attrs: args[:script_attrs].merge("data-immediate-hydration" => true), + script_content: args[:script_content], + additional_scripts: [script_tag] + } + else + { script_attrs: args[:script_attrs], script_content: args[:script_content], additional_scripts: [] } + end + end + + def self.enhance_store_script_data(args) + if args[:redux_store_data][:immediate_hydration] + store_name = args[:redux_store_data][:store_name] + script_tag = "" + { + script_attrs: args[:script_attrs].merge("data-immediate-hydration" => true), + script_content: args[:script_content], + additional_scripts: [script_tag] + } + else + { script_attrs: args[:script_attrs], script_content: args[:script_content], additional_scripts: [] } + end + end + end) - around do |example| - ReactOnRails.configure { |config| config.immediate_hydration = true } - example.run - ReactOnRails.configure { |config| config.immediate_hydration = false } + # Mock Gem.loaded_specs for version + gem_spec = Struct.new(:version).new(Gem::Version.new("1.0.0")) + allow(Gem).to receive(:loaded_specs).and_return({ "react_on_rails_pro" => gem_spec }) end end diff --git a/spec/react_on_rails/react_component/render_options_spec.rb b/spec/react_on_rails/react_component/render_options_spec.rb index 9c1dcc7b21..02333fd252 100644 --- a/spec/react_on_rails/react_component/render_options_spec.rb +++ b/spec/react_on_rails/react_component/render_options_spec.rb @@ -9,7 +9,6 @@ replay_console raise_on_prerender_error random_dom_id - immediate_hydration ].freeze def the_attrs(react_component_name: "App", options: {}) @@ -164,4 +163,62 @@ def the_attrs(react_component_name: "App", options: {}) end end end + + # Pro feature tests - immediate_hydration is retrieved from Pro gem configuration + describe "#immediate_hydration" do + context "with immediate_hydration option set to true" do + it "returns true" do + options = { immediate_hydration: true } + attrs = the_attrs(options: options) + + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be true + end + end + + context "with immediate_hydration option set to false" do + it "returns false" do + options = { immediate_hydration: false } + attrs = the_attrs(options: options) + + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be false + end + end + + context "without immediate_hydration option" do + context "when Pro gem is installed" do + it "returns value from ReactOnRailsPro.configuration" do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + + # Stub ReactOnRailsPro module and configuration + config_struct = Struct.new(:immediate_hydration) + pro_config = config_struct.new(true) + + pro_module = Module.new do + define_singleton_method(:configuration) { pro_config } + end + stub_const("ReactOnRailsPro", pro_module) + + attrs = the_attrs + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be true + end + end + + context "when Pro gem is NOT installed" do + it "returns nil" do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) + + attrs = the_attrs + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be_nil + end + end + end + end end