diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6d318fd4..44983d884a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,12 +49,30 @@ Changes since the last non-beta release. - **Shakapacker 9.2.0 Upgrade**: Upgraded Shakapacker from 9.1.0 to 9.2.0. This minor version update adds a new `bin/shakapacker-config` utility for debugging webpack/rspack configurations with doctor mode, save mode, and stdout mode options. Supports YAML, JSON, and Node.js inspect output formats. by [justin808](https://github.com/justin808). +- **Removed Pro Warning Badge**: Removed the visual warning badge that appeared when non-Pro users attempted to enable Pro-only features like `immediate_hydration`. Pro features are now silently disabled when a Pro license is not available, providing a cleaner user experience without intrusive warning banners. [PR #1993](https://github.com/shakacode/react_on_rails/pull/1993) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + +- **`immediate_hydration` now automatically enabled for Pro users**: The `config.immediate_hydration` configuration option has been removed. Immediate hydration is now automatically enabled for React on Rails Pro users and disabled for non-Pro users, simplifying configuration while providing optimal performance by default. Component-level overrides are still supported via the `immediate_hydration` parameter on `react_component`, `redux_store`, and `stream_react_component` helpers. [PR 1997](https://github.com/shakacode/react_on_rails/pull/1997) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + +- **`generated_component_packs_loading_strategy` now defaults based on Pro license**: When using Shakapacker >= 8.2.0, the default loading strategy is now `:async` for Pro users and `:defer` for non-Pro users. This provides optimal performance for Pro users while maintaining compatibility for non-Pro users. You can still explicitly set the strategy in your configuration. [PR #1993](https://github.com/shakacode/react_on_rails/pull/1993) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + #### Bug Fixes - **Use as Git dependency**: All packages can now be installed as Git dependencies. This is useful for development and testing purposes. See [CONTRIBUTING.md](./CONTRIBUTING.md#git-dependencies) for documentation. [PR #1873](https://github.com/shakacode/react_on_rails/pull/1873) by [alexeyr-ci2](https://github.com/alexeyr-ci2). #### Breaking Changes +- **`config.immediate_hydration` configuration removed**: The `config.immediate_hydration` setting in `config/initializers/react_on_rails.rb` has been removed. Immediate hydration is now automatically enabled for React on Rails Pro users and automatically disabled for non-Pro users. + + **Migration steps:** + + - Remove any `config.immediate_hydration = true` or `config.immediate_hydration = false` lines from your `config/initializers/react_on_rails.rb` file + - Pro users: No action needed - immediate hydration is now enabled automatically for optimal performance + - Non-Pro users: No action needed - standard hydration behavior continues to work as before + - Component-level overrides: You can still override behavior per-component using `react_component("MyComponent", immediate_hydration: false)` or `redux_store("MyStore", immediate_hydration: true)` + - If a non-Pro user explicitly sets `immediate_hydration: true` on a component or store, a warning will be logged and it will be enforced to fall back to standard hydration (the value will be overridden to `false`) + + [PR 1997](https://github.com/shakacode/react_on_rails/pull/1997) by [AbanoubGhadban](https://github.com/AbanoubGhadban). + - **React on Rails Core Package**: Several Pro-only methods have been removed from the core package and are now exclusively available in the `react-on-rails-pro` package. If you're using any of the following methods, you'll need to migrate to React on Rails Pro: - `getOrWaitForComponent()` - `getOrWaitForStore()` diff --git a/lib/react_on_rails/configuration.rb b/lib/react_on_rails/configuration.rb index 6d89cd071e..bff1d20bca 100644 --- a/lib/react_on_rails/configuration.rb +++ b/lib/react_on_rails/configuration.rb @@ -41,8 +41,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. @@ -64,10 +62,55 @@ 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, + :generated_component_packs_loading_strategy, :component_registry_timeout, :server_bundle_output_path, :enforce_private_server_bundles + # Class instance variable and mutex to track if deprecation warning has been shown + # Using mutex to ensure thread-safety in multi-threaded environments + @immediate_hydration_warned = false + @immediate_hydration_mutex = Mutex.new + + class << self + attr_accessor :immediate_hydration_warned, :immediate_hydration_mutex + end + + # Deprecated: immediate_hydration configuration has been removed + def immediate_hydration=(value) + warned = false + self.class.immediate_hydration_mutex.synchronize do + warned = self.class.immediate_hydration_warned + self.class.immediate_hydration_warned = true unless warned + end + + return if warned + + Rails.logger.warn <<~WARNING + [REACT ON RAILS] The 'config.immediate_hydration' configuration option is deprecated and no longer used. + Immediate hydration is now automatically enabled for React on Rails Pro users. + Please remove 'config.immediate_hydration = #{value}' from your config/initializers/react_on_rails.rb file. + See CHANGELOG.md for migration instructions. + WARNING + end + + def immediate_hydration + warned = false + self.class.immediate_hydration_mutex.synchronize do + warned = self.class.immediate_hydration_warned + self.class.immediate_hydration_warned = true unless warned + end + + return nil if warned + + Rails.logger.warn <<~WARNING + [REACT ON RAILS] The 'config.immediate_hydration' configuration option is deprecated and no longer used. + Immediate hydration is now automatically enabled for React on Rails Pro users. + Please remove any references to 'config.immediate_hydration' from your config/initializers/react_on_rails.rb file. + See CHANGELOG.md for migration instructions. + WARNING + nil + end + # rubocop:disable Metrics/AbcSize def initialize(node_modules_location: nil, server_bundle_js_file: nil, prerender: nil, replay_console: nil, make_generated_server_bundle_the_entrypoint: nil, @@ -81,7 +124,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, 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 self.generated_assets_dirs = generated_assets_dirs @@ -122,7 +165,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 @@ -177,7 +219,8 @@ def validate_generated_component_packs_loading_strategy 2. Upgrade to Shakapacker v8.2.0 or above to enable async script loading MSG if PackerUtils.supports_async_loading? - self.generated_component_packs_loading_strategy ||= :async + # Default based on Pro license: Pro users get :async, non-Pro users get :defer + self.generated_component_packs_loading_strategy ||= (Utils.react_on_rails_pro? ? :async : :defer) elsif generated_component_packs_loading_strategy.nil? Rails.logger.warn("**WARNING** #{msg}") self.generated_component_packs_loading_strategy = :sync diff --git a/lib/react_on_rails/controller.rb b/lib/react_on_rails/controller.rb index ae254b1a5a..dae80a838b 100644 --- a/lib/react_on_rails/controller.rb +++ b/lib/react_on_rails/controller.rb @@ -9,13 +9,13 @@ module Controller # JavaScript code. # props: Named parameter props which is a Ruby Hash or JSON string which contains the properties # to pass to the redux store. - # immediate_hydration: 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. + # immediate_hydration: React on Rails Pro (licensed) feature. When nil (default), Pro users get + # immediate hydration, non-Pro users don't. Can be explicitly overridden. # # 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? + immediate_hydration = ReactOnRails::Utils.normalize_immediate_hydration(immediate_hydration, store_name, "Store") redux_store_data = { store_name: store_name, props: props, immediate_hydration: immediate_hydration } diff --git a/lib/react_on_rails/doctor.rb b/lib/react_on_rails/doctor.rb index 54ed06dbec..efe5f80006 100644 --- a/lib/react_on_rails/doctor.rb +++ b/lib/react_on_rails/doctor.rb @@ -732,10 +732,12 @@ def analyze_performance_config(content) auto_load_match = content.match(/config\.auto_load_bundle\s*=\s*([^\s\n,]+)/) checker.add_info(" auto_load_bundle: #{auto_load_match[1]}") if auto_load_match - # Immediate hydration (Pro feature) + # Deprecated immediate_hydration setting immediate_hydration_match = content.match(/config\.immediate_hydration\s*=\s*([^\s\n,]+)/) if immediate_hydration_match - checker.add_info(" immediate_hydration: #{immediate_hydration_match[1]} (React on Rails Pro)") + checker.add_warning(" ⚠️ immediate_hydration: #{immediate_hydration_match[1]} (DEPRECATED)") + checker.add_info(" 💡 This setting is no longer used. Immediate hydration is now automatic for Pro users.") + checker.add_info(" 💡 Remove this line from your config/initializers/react_on_rails.rb file.") end # Component registry timeout diff --git a/lib/react_on_rails/helper.rb b/lib/react_on_rails/helper.rb index 92f4e0c375..6ef91a2f98 100644 --- a/lib/react_on_rails/helper.rb +++ b/lib/react_on_rails/helper.rb @@ -155,10 +155,10 @@ def react_component_hash(component_name, options = {}) # props: Ruby Hash or JSON string which contains the properties to pass to the redux store. # Options # defer: false -- pass as true if you wish to render this below your component. - # 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. + # immediate_hydration: nil -- React on Rails Pro (licensed) feature. When nil (default), Pro users + # get immediate hydration, non-Pro users don't. Can be explicitly overridden. def redux_store(store_name, props: {}, defer: false, immediate_hydration: nil) - immediate_hydration = ReactOnRails.configuration.immediate_hydration if immediate_hydration.nil? + immediate_hydration = ReactOnRails::Utils.normalize_immediate_hydration(immediate_hydration, store_name, "Store") redux_store_data = { store_name: store_name, props: props, diff --git a/lib/react_on_rails/pro_helper.rb b/lib/react_on_rails/pro_helper.rb index 41d0a51763..4f3c020d77 100644 --- a/lib/react_on_rails/pro_helper.rb +++ b/lib/react_on_rails/pro_helper.rb @@ -2,10 +2,6 @@ module ReactOnRails module ProHelper - 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) @@ -36,17 +32,12 @@ def generate_component_script(render_options) 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 + 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::ProUtils.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", @@ -67,40 +58,7 @@ def generate_store_script(redux_store_data) 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. " + store_hydration_scripts.html_safe 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 bf19e9bf78..0000000000 --- a/lib/react_on_rails/pro_utils.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module ReactOnRails - module ProUtils - PRO_ONLY_OPTIONS = %i[immediate_hydration].freeze - - # Checks if React on Rails Pro features are available - # @return [Boolean] true if Pro is installed and licensed, false otherwise - def self.support_pro_features? - ReactOnRails::Utils.react_on_rails_pro? - 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 diff --git a/lib/react_on_rails/react_component/render_options.rb b/lib/react_on_rails/react_component/render_options.rb index 3d9a4cdf05..deffd7fb61 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::ProUtils.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,11 @@ def logging_on_server end def immediate_hydration - retrieve_configuration_value_for(:immediate_hydration) + ReactOnRails::Utils.normalize_immediate_hydration( + options[:immediate_hydration], + react_component_name, + "Component" + ) end def to_s diff --git a/lib/react_on_rails/utils.rb b/lib/react_on_rails/utils.rb index fa96692f86..a9d60acbff 100644 --- a/lib/react_on_rails/utils.rb +++ b/lib/react_on_rails/utils.rb @@ -14,6 +14,46 @@ module Utils Rainbow('To see the full output, set FULL_TEXT_ERRORS=true.').red } ...\n".freeze + def self.immediate_hydration_pro_license_warning(name, type = "Component") + "[REACT ON RAILS] Warning: immediate_hydration: true requires a React on Rails Pro license.\n" \ + "#{type} '#{name}' will fall back to standard hydration behavior.\n" \ + "Visit https://www.shakacode.com/react-on-rails-pro/ for licensing information." + end + + # Normalizes the immediate_hydration option value, enforcing Pro license requirements. + # Returns the normalized boolean value for immediate_hydration. + # + # @param value [Boolean, nil] The immediate_hydration option value + # @param name [String] The name of the component/store (for warning messages) + # @param type [String] The type ("Component" or "Store") for warning messages + # @return [Boolean] The normalized immediate_hydration value + # @raise [ArgumentError] If value is not a boolean or nil + # + # Logic: + # - Validates that value is true, false, or nil + # - If value is explicitly true (boolean) and no Pro license: warn and return false + # - If value is nil: return true for Pro users, false for non-Pro users + # - Otherwise: return the value as-is (allows explicit false to work) + def self.normalize_immediate_hydration(value, name, type = "Component") + # Type validation: only accept boolean or nil + unless [true, false, nil].include?(value) + raise ArgumentError, + "[REACT ON RAILS] immediate_hydration must be true, false, or nil. Got: #{value.inspect} (#{value.class})" + end + + # Strict equality check: only trigger warning for explicit boolean true + if value == true && !react_on_rails_pro? + Rails.logger.warn immediate_hydration_pro_license_warning(name, type) + return false + end + + # If nil, default based on Pro license status + return react_on_rails_pro? if value.nil? + + # Return explicit value (including false) + value + end + # https://forum.shakacode.com/t/yak-of-the-week-ruby-2-4-pathname-empty-changed-to-look-at-file-size/901 # return object if truthy, else return nil def self.truthy_presence(obj) 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 1a691fdc38..a13c61017a 100644 --- a/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb +++ b/spec/dummy/spec/helpers/react_on_rails_helper_spec.rb @@ -38,15 +38,8 @@ def self.pro_attribution_comment stub_const("ReactOnRailsPro", pro_module) stub_const("ReactOnRailsPro::Utils", utils_module) - # Configure immediate_hydration to true for tests since they expect that behavior - ReactOnRails.configure do |config| - config.immediate_hydration = true - end - end - - after do - # Reset to default - avoid validation issues by setting directly - ReactOnRails.configuration.immediate_hydration = false + # Stub react_on_rails_pro? to return true for tests since they expect that behavior + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) end let(:hash) do @@ -392,78 +385,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?).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?).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?).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?: 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 @@ -486,40 +407,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?).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?: 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 @@ -545,49 +432,40 @@ def helper.append_javascript_pack_tag(name, **options) 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" } - + context "without Pro license" do before do - allow(Rails.logger).to receive(:warn) + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) end - context "when Pro license is NOT installed and immediate_hydration is true" do - subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: true) } + context "with immediate_hydration option set to true (not recommended)" do + it "returns false for immediate_hydration and logs a warning" do + expect(Rails.logger).to receive(:warn).with(/immediate_hydration: true requires a React on Rails Pro license/) - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) - end - - it { is_expected.to include(badge_html_string) } + result = redux_store("reduxStore", props: props, immediate_hydration: true) - it "logs a warning" do - store - expect(Rails.logger).to have_received(:warn) - .with(a_string_matching(/The 'immediate_hydration' feature requires/)) + # Verify that the store tag does NOT have immediate hydration enabled + expect(result).not_to include('data-immediate-hydration="true"') 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) } + context "with immediate_hydration option set to false" do + it "returns false for immediate_hydration without warning" do + expect(Rails.logger).not_to receive(:warn) - before do - allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) - end + result = redux_store("reduxStore", props: props, immediate_hydration: false) - it { is_expected.not_to include(badge_html_string) } + # Verify that the store tag does NOT have immediate hydration enabled + expect(result).not_to include('data-immediate-hydration="true"') + end end - context "when Pro license IS installed and immediate_hydration is true" do - subject(:store) { redux_store("reduxStore", props: props, immediate_hydration: true) } + context "without immediate_hydration option (nil)" do + it "defaults to false for non-Pro users" do + result = redux_store("reduxStore", props: props) - before do - allow(ReactOnRails::Utils).to receive_messages( - react_on_rails_pro?: true - ) + # Verify that the store tag does NOT have immediate hydration enabled + expect(result).not_to include('data-immediate-hydration="true"') end - - it { is_expected.not_to include(badge_html_string) } end end end diff --git a/spec/dummy/spec/system/integration_spec.rb b/spec/dummy/spec/system/integration_spec.rb index 57212d1ca0..452606b289 100644 --- a/spec/dummy/spec/system/integration_spec.rb +++ b/spec/dummy/spec/system/integration_spec.rb @@ -104,12 +104,9 @@ def self.pro_attribution_comment end stub_const("ReactOnRailsPro", pro_module) stub_const("ReactOnRailsPro::Utils", utils_module) - end - around do |example| - ReactOnRails.configure { |config| config.immediate_hydration = true } - example.run - ReactOnRails.configure { |config| config.immediate_hydration = false } + # Stub react_on_rails_pro? to return true for tests since they expect that behavior + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) end end diff --git a/spec/react_on_rails/configuration_spec.rb b/spec/react_on_rails/configuration_spec.rb index e3123fcdb0..7d90b7ffcc 100644 --- a/spec/react_on_rails/configuration_spec.rb +++ b/spec/react_on_rails/configuration_spec.rb @@ -284,9 +284,26 @@ module ReactOnRails .with("8.2.0").and_return(true) end - it "defaults to :async" do - ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock - expect(ReactOnRails.configuration.generated_component_packs_loading_strategy).to eq(:async) + context "with Pro license" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + end + + it "defaults to :async" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + expect(ReactOnRails.configuration.generated_component_packs_loading_strategy).to eq(:async) + end + end + + context "without Pro license" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) + end + + it "defaults to :defer" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + expect(ReactOnRails.configuration.generated_component_packs_loading_strategy).to eq(:defer) + end end it "accepts :async value" do @@ -366,6 +383,118 @@ module ReactOnRails end end + describe ".immediate_hydration (deprecated)" do + before do + # Reset the warning flag before each test + described_class.immediate_hydration_warned = false + allow(Rails.logger).to receive(:warn) + end + + after do + # Reset the warning flag after each test + described_class.immediate_hydration_warned = false + end + + describe "setter" do + it "logs a deprecation warning when setting to true" do + ReactOnRails.configure do |config| + config.immediate_hydration = true + end + + expect(Rails.logger).to have_received(:warn) + .with(/immediate_hydration' configuration option is deprecated/) + end + + it "logs a deprecation warning when setting to false" do + ReactOnRails.configure do |config| + config.immediate_hydration = false + end + + expect(Rails.logger).to have_received(:warn) + .with(/immediate_hydration' configuration option is deprecated/) + end + + it "mentions the value in the warning message" do + ReactOnRails.configure do |config| + config.immediate_hydration = true + end + + expect(Rails.logger).to have_received(:warn) do |message| + expect(message).to include("config.immediate_hydration = true") + end + end + + it "only logs the warning once even if called multiple times" do + ReactOnRails.configure do |config| + config.immediate_hydration = true + config.immediate_hydration = false + config.immediate_hydration = true + end + + expect(Rails.logger).to have_received(:warn).once + end + end + + describe "getter" do + it "logs a deprecation warning when accessed" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + + ReactOnRails.configuration.immediate_hydration + + expect(Rails.logger).to have_received(:warn) + .with(/immediate_hydration' configuration option is deprecated/) + end + + it "returns nil" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + + result = ReactOnRails.configuration.immediate_hydration + + expect(result).to be_nil + end + + it "only logs the warning once even if called multiple times" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + + ReactOnRails.configuration.immediate_hydration + ReactOnRails.configuration.immediate_hydration + ReactOnRails.configuration.immediate_hydration + + expect(Rails.logger).to have_received(:warn).once + end + end + + describe "setter and getter interactions" do + it "does not warn again on getter if setter already warned" do + ReactOnRails.configure do |config| + config.immediate_hydration = true + end + + expect(Rails.logger).to have_received(:warn).once + + ReactOnRails.configuration.immediate_hydration + + # Still only one warning total + expect(Rails.logger).to have_received(:warn).once + end + + it "does not warn again on setter if getter already warned" do + ReactOnRails.configure {} # rubocop:disable Lint/EmptyBlock + + ReactOnRails.configuration.immediate_hydration + + expect(Rails.logger).to have_received(:warn).once + + ReactOnRails.configure do |config| + config.immediate_hydration = true + end + + # Still only one warning total + expect(Rails.logger).to have_received(:warn).once + end + end + end + describe "enforce_private_server_bundles validation" do context "when enforce_private_server_bundles is true" do before do 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 8ef62cba5c..5c6aa15836 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,98 @@ def the_attrs(react_component_name: "App", options: {}) end end end + + describe "#immediate_hydration" do + context "with Pro license" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(true) + end + + 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 (override)" 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 + it "returns true (Pro default)" do + attrs = the_attrs + + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be true + end + end + end + + context "without Pro license" do + before do + allow(ReactOnRails::Utils).to receive(:react_on_rails_pro?).and_return(false) + end + + context "with immediate_hydration option set to true (not recommended)" do + it "returns false and logs a warning (enforces fallback)" do + options = { immediate_hydration: true } + attrs = the_attrs(options: options) + + expect(Rails.logger).to receive(:warn).with(/immediate_hydration: true requires a React on Rails Pro license/) + + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be false + 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 + it "returns false (non-Pro default)" do + attrs = the_attrs + + opts = described_class.new(**attrs) + + expect(opts.immediate_hydration).to be false + end + end + + context "with invalid immediate_hydration value" do + it "raises ArgumentError for non-boolean values" do + options = { immediate_hydration: "yes" } + attrs = the_attrs(options: options) + + opts = described_class.new(**attrs) + + expect { opts.immediate_hydration }.to raise_error( + ArgumentError, + /immediate_hydration must be true, false, or nil/ + ) + end + end + end + end end diff --git a/spec/react_on_rails/utils_spec.rb b/spec/react_on_rails/utils_spec.rb index b43f4d9444..908f00d958 100644 --- a/spec/react_on_rails/utils_spec.rb +++ b/spec/react_on_rails/utils_spec.rb @@ -898,6 +898,100 @@ def self.configuration=(config) end # RSC utility method tests moved to react_on_rails_pro/spec/react_on_rails_pro/utils_spec.rb + + describe ".normalize_immediate_hydration" do + context "with Pro license" do + before do + allow(described_class).to receive(:react_on_rails_pro?).and_return(true) + end + + it "returns true when value is explicitly true" do + result = described_class.normalize_immediate_hydration(true, "TestComponent", "Component") + expect(result).to be true + end + + it "returns false when value is explicitly false" do + result = described_class.normalize_immediate_hydration(false, "TestComponent", "Component") + expect(result).to be false + end + + it "returns true when value is nil (Pro default)" do + result = described_class.normalize_immediate_hydration(nil, "TestComponent", "Component") + expect(result).to be true + end + + it "does not log a warning for any valid value" do + expect(Rails.logger).not_to receive(:warn) + + described_class.normalize_immediate_hydration(true, "TestComponent", "Component") + described_class.normalize_immediate_hydration(false, "TestComponent", "Component") + described_class.normalize_immediate_hydration(nil, "TestComponent", "Component") + end + end + + context "without Pro license" do + before do + allow(described_class).to receive(:react_on_rails_pro?).and_return(false) + end + + it "returns false and logs warning when value is explicitly true" do + expect(Rails.logger).to receive(:warn) + .with(/immediate_hydration: true requires a React on Rails Pro license/) + + result = described_class.normalize_immediate_hydration(true, "TestComponent", "Component") + expect(result).to be false + end + + it "returns false when value is explicitly false" do + expect(Rails.logger).not_to receive(:warn) + + result = described_class.normalize_immediate_hydration(false, "TestComponent", "Component") + expect(result).to be false + end + + it "returns false when value is nil (non-Pro default)" do + expect(Rails.logger).not_to receive(:warn) + + result = described_class.normalize_immediate_hydration(nil, "TestComponent", "Component") + expect(result).to be false + end + + it "includes component name and type in warning message" do + expect(Rails.logger).to receive(:warn) do |message| + expect(message).to include("TestStore") + expect(message).to include("Store") + end + + described_class.normalize_immediate_hydration(true, "TestStore", "Store") + end + end + + context "with invalid values" do + it "raises ArgumentError for string values" do + expect do + described_class.normalize_immediate_hydration("yes", "TestComponent", "Component") + end.to raise_error(ArgumentError, /immediate_hydration must be true, false, or nil/) + end + + it "raises ArgumentError for numeric values" do + expect do + described_class.normalize_immediate_hydration(1, "TestComponent", "Component") + end.to raise_error(ArgumentError, /immediate_hydration must be true, false, or nil/) + end + + it "raises ArgumentError for hash values" do + expect do + described_class.normalize_immediate_hydration({}, "TestComponent", "Component") + end.to raise_error(ArgumentError, /immediate_hydration must be true, false, or nil/) + end + + it "includes the invalid value in error message" do + expect do + described_class.normalize_immediate_hydration("invalid", "TestComponent", "Component") + end.to raise_error(ArgumentError, /Got: "invalid" \(String\)/) + end + end + end end end # rubocop:enable Metrics/ModuleLength, Metrics/BlockLength