From 5a0e4941adbf0451541ca3c37be1a7a27e6808e1 Mon Sep 17 00:00:00 2001 From: Fiona Burcombe <20680337+finalburn@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:12:45 +0000 Subject: [PATCH 1/3] Raise when setting unknown config.view_component options `config.view_component` and its `previews` and `generate` namespaces were plain `ActiveSupport::OrderedOptions`, so any key could be set and options renamed or removed in 4.0 (such as `preview_controller`) were silently ignored, as were typos. They're now `ViewComponent::Config::Options`, which raises `ViewComponent::UnknownConfigOptionError` when an unknown option is set. For options renamed or removed in 4.0, the error names the replacement. Reading an unknown option still returns `nil`. `Options` also supports deprecating an option before it starts raising: an old name listed in `deprecated` warns and forwards to its replacement, and a test fails once the deprecation horizon is reached while any deprecated options remain. Fixes #2754 Co-Authored-By: Claude Opus 5.5 --- docs/CHANGELOG.md | 4 + lib/view_component/config.rb | 116 ++++++++++++++++++++++++-- lib/view_component/errors.rb | 16 ++++ test/sandbox/test/config_test.rb | 135 +++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8c271729b..ef8aca961 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,10 @@ nav_order: 6 *Joel Hawksley* +* Raise `ViewComponent::UnknownConfigOptionError` when setting a `config.view_component` option that doesn't exist, rather than silently ignoring it. This includes options renamed or removed in 4.0, such as `preview_controller` (now `previews.controller`), and the error names the replacement. + + *Fiona Burcombe* + ## 4.20.0 * Fix `invalid byte sequence in UTF-8` errors when precomputing cache digests for components loaded from encrypted or binary source files. diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index 0ad336838..adf6c7b3d 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -1,9 +1,98 @@ # frozen_string_literal: true require "view_component/deprecation" +require "view_component/errors" module ViewComponent class Config + # An `ActiveSupport::OrderedOptions` that raises when an option it doesn't + # know about is set, rather than silently storing it. + # + # To rename an option, first add the old name to `deprecated`, mapped to + # the path of the new option relative to this namespace (or `nil` if the + # option is being removed). Setting or reading the old name then emits a + # deprecation warning and uses the new option. In the next major version, + # move the old name to `renamed` (or `removed`) so that setting it raises. + class Options < ActiveSupport::OrderedOptions # :nodoc: + ROOT_NAMESPACE = "config.view_component" + + def initialize(namespace, allowed_keys, default: nil, renamed: {}, removed: {}, deprecated: {}) + super(default) + @namespace = namespace + @allowed_keys = allowed_keys + @renamed = renamed + @removed = removed + @deprecated_options = deprecated + end + + attr_reader :deprecated_options + + def []=(key, value) + key = key.to_sym + + if @deprecated_options.key?(key) + warn_deprecated(key) + path = @deprecated_options[key] + return if path.nil? + + path[0...-1].reduce(self) { |options, path_key| options[path_key] }[path.last] = value + elsif @allowed_keys.include?(key) + super + else + raise UnknownConfigOptionError.new( + "#{@namespace}.#{key}", + renamed_to: @renamed[key] && "#{ROOT_NAMESPACE}.#{@renamed[key]}", + removed: @removed.key?(key), + hint: @removed[key], + valid_options: @allowed_keys + ) + end + end + + def [](key) + key = key.to_sym + return super unless @deprecated_options.key?(key) + + warn_deprecated(key) + @deprecated_options[key]&.reduce(self) { |options, path_key| options[path_key] } + end + + private + + def warn_deprecated(key) + path = @deprecated_options[key] + message = "`#{@namespace}.#{key}` is deprecated and will be removed in ViewComponent #{DEPRECATION_HORIZON}." + message += " Use `#{@namespace}.#{path.join(".")}` instead." if path + # Point the warning at the app's config rather than at this file. + ViewComponent::Deprecation.warn(message, caller_locations.drop_while { |location| location.path == __FILE__ }) + end + end + + # Options renamed in 4.0, mapped to their new name. + RENAMED_OPTIONS = { + preview_controller: "previews.controller", + preview_route: "previews.route", + preview_paths: "previews.paths", + show_previews: "previews.enabled", + default_preview_layout: "previews.default_layout", + component_parent_class: "generate.parent_class", + view_component_path: "generate.path" + }.freeze + + # Generator options renamed in 4.0, mapped to their new name. + RENAMED_GENERATE_OPTIONS = { + component_parent_class: "generate.parent_class" + }.freeze + + # Options removed in 4.0, mapped to a hint about what to do instead. + REMOVED_OPTIONS = { + test_controller: "Use the `vc_test_controller_class` test helper instead.", + render_monkey_patch_enabled: nil, + use_deprecated_instrumentation_name: "Events always use the `render.view_component` name.", + capture_compatibility_patch_enabled: nil, + show_previews_source: "Consider using Lookbook instead." + }.freeze + class << self # `new` without any arguments initializes the default configuration, but # it's important to differentiate in case that's no longer the case in @@ -11,11 +100,16 @@ class << self alias_method :default, :new def defaults - ActiveSupport::OrderedOptions.new.merge!({ - generate: default_generate_options, - previews: default_previews_options, - instrumentation_enabled: false - }) + options = Options.new( + Options::ROOT_NAMESPACE, + %i[generate previews instrumentation_enabled], + renamed: RENAMED_OPTIONS, + removed: REMOVED_OPTIONS + ) + options.generate = default_generate_options + options.previews = default_previews_options + options.instrumentation_enabled = false + options end # @!attribute generate @@ -156,14 +250,22 @@ def registered_rails_engines_with_previews end def default_generate_options - options = ActiveSupport::OrderedOptions.new(false) + options = Options.new( + "#{Options::ROOT_NAMESPACE}.generate", + %i[path preview_path parent_class sidecar stimulus_controller typescript locale distinct_locale_files preview use_component_path_for_rspec_tests], + default: false, + renamed: RENAMED_GENERATE_OPTIONS + ) options.preview_path = "" options.path = "app/components" options end def default_previews_options - options = ActiveSupport::OrderedOptions.new + options = Options.new( + "#{Options::ROOT_NAMESPACE}.previews", + %i[controller route enabled default_layout paths] + ) options.controller = "ViewComponentsController" options.route = "/rails/view_components" options.enabled = defined?(Rails.env) && (Rails.env.development? || Rails.env.test?) diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index af3059d75..e0552e573 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -267,4 +267,20 @@ def initialize(component_name) super(MESSAGE.gsub("COMPONENT", component_name.to_s)) end end + + class UnknownConfigOptionError < StandardError + def initialize(option, renamed_to: nil, removed: false, hint: nil, valid_options: []) + message = + if renamed_to + "`#{option}` was renamed to `#{renamed_to}` in ViewComponent 4.0." + elsif removed + "`#{option}` was removed in ViewComponent 4.0." + else + "`#{option}` isn't a ViewComponent configuration option. " \ + "Valid options are: #{valid_options.join(", ")}." + end + + super([message, hint].compact.join(" ")) + end + end end diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index 9347a6ac1..6eac7188f 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -16,6 +16,129 @@ def test_defaults_are_correct assert_equal @config.previews.paths, ["#{Rails.root}/test/components/previews"] end + def test_setting_documented_options_does_not_raise + @config.instrumentation_enabled = true + @config.previews.controller = "MyPreviewController" + @config.previews.route = "/my_previews" + @config.previews.enabled = false + @config.previews.default_layout = "preview_layout" + @config.previews.paths = [] + %i[ + path preview_path parent_class sidecar stimulus_controller typescript + locale distinct_locale_files preview use_component_path_for_rspec_tests + ].each { |option| @config.generate[option] = true } + + assert_equal true, @config.generate.typescript + end + + def test_setting_an_option_renamed_in_4_0_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.preview_controller = "MyPreviewController" + end + + assert_equal( + "`config.view_component.preview_controller` was renamed to " \ + "`config.view_component.previews.controller` in ViewComponent 4.0.", + error.message + ) + end + + def test_setting_an_unknown_option_with_hash_syntax_raises + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config[:preview_controller] = "MyPreviewController" + end + end + + def test_setting_a_generate_option_renamed_in_4_0_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.generate.component_parent_class = "MyBaseComponent" + end + + assert_includes error.message, "`config.view_component.generate.parent_class`" + end + + def test_setting_an_option_removed_in_4_0_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.test_controller = "MyController" + end + + assert_equal( + "`config.view_component.test_controller` was removed in ViewComponent 4.0. " \ + "Use the `vc_test_controller_class` test helper instead.", + error.message + ) + end + + def test_setting_a_misspelled_option_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.instrumentaton_enabled = true + end + + assert_equal( + "`config.view_component.instrumentaton_enabled` isn't a ViewComponent configuration option. " \ + "Valid options are: generate, previews, instrumentation_enabled.", + error.message + ) + end + + def test_setting_a_misspelled_nested_option_raises + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.previews.contoller = "MyPreviewController" + end + + assert_includes error.message, "`config.view_component.previews.contoller`" + end + + def test_reading_an_unknown_option_returns_nil + assert_nil @config.preview_controller + end + + def test_duplicated_options_are_strict + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.generate.dup.component_parent_class = "MyBaseComponent" + end + end + + def test_generate_options_default_to_false + assert_equal false, @config.generate.typescript + end + + def test_setting_a_deprecated_option_sets_its_replacement + options = deprecated_options + + assert_deprecated(/`test.nested.new_name` instead/, ViewComponent::Deprecation) do + options.old_name = "value" + end + assert_equal "value", options.nested.new_name + end + + def test_reading_a_deprecated_option_reads_its_replacement + options = deprecated_options + options.nested.new_name = "value" + + assert_deprecated(/`test.old_name` is deprecated/, ViewComponent::Deprecation) do + assert_equal "value", options.old_name + end + end + + def test_setting_a_deprecated_removed_option_stores_nothing + options = deprecated_options + + assert_deprecated(/`test.removed_name` is deprecated/, ViewComponent::Deprecation) do + options.removed_name = "value" + end + assert_equal({nested: {}}, options.to_h) + end + + def test_no_deprecated_options_remain_past_the_deprecation_horizon + skip if Gem::Version.new(ViewComponent::DEPRECATION_HORIZON).segments.first > ViewComponent::VERSION::MAJOR + + defaults = ViewComponent::Config.defaults + options = [defaults, defaults.generate, defaults.previews] + assert options.all? { |namespace| namespace.deprecated_options.empty? }, + "Move deprecated options to `RENAMED_OPTIONS` or `REMOVED_OPTIONS` so that setting them raises." + end + def test_all_methods_are_documented require "yard" require "rake" @@ -36,5 +159,17 @@ def test_all_methods_are_documented assert configuration_methods_to_document.map(&:docstring).all?(&:present?), "Configuration options are missing docstrings." end + + private + + def deprecated_options + options = ViewComponent::Config::Options.new( + "test", + %i[nested], + deprecated: {old_name: %i[nested new_name], removed_name: nil} + ) + options.nested = ViewComponent::Config::Options.new("test.nested", %i[new_name]) + options + end end end From e02cbeea0ab504a7a0b81d408893263413b5a819 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 29 Oct 2026 11:03:03 -0600 Subject: [PATCH 2/3] Simplify strict configuration options --- docs/CHANGELOG.md | 2 +- lib/view_component/config.rb | 50 +++++-------------------- test/sandbox/test/config_test.rb | 64 +++++++++++++++----------------- 3 files changed, 40 insertions(+), 76 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ef8aca961..5b0e6c24f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,7 +14,7 @@ nav_order: 6 *Joel Hawksley* -* Raise `ViewComponent::UnknownConfigOptionError` when setting a `config.view_component` option that doesn't exist, rather than silently ignoring it. This includes options renamed or removed in 4.0, such as `preview_controller` (now `previews.controller`), and the error names the replacement. +* Raise `ViewComponent::UnknownConfigOptionError` when setting a `config.view_component` option that doesn't exist, rather than silently ignoring it. This includes options renamed or removed in 4.0, such as `preview_controller` (now `previews.controller`), and the error names the replacement. Replacing `previews` or `generate` with a hash preserves this validation. *Fiona Burcombe* diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index adf6c7b3d..16632cf18 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -1,42 +1,33 @@ # frozen_string_literal: true -require "view_component/deprecation" require "view_component/errors" module ViewComponent class Config # An `ActiveSupport::OrderedOptions` that raises when an option it doesn't # know about is set, rather than silently storing it. - # - # To rename an option, first add the old name to `deprecated`, mapped to - # the path of the new option relative to this namespace (or `nil` if the - # option is being removed). Setting or reading the old name then emits a - # deprecation warning and uses the new option. In the next major version, - # move the old name to `renamed` (or `removed`) so that setting it raises. class Options < ActiveSupport::OrderedOptions # :nodoc: ROOT_NAMESPACE = "config.view_component" - def initialize(namespace, allowed_keys, default: nil, renamed: {}, removed: {}, deprecated: {}) + def initialize(namespace, allowed_keys, default: nil, renamed: {}, removed: {}) super(default) @namespace = namespace @allowed_keys = allowed_keys @renamed = renamed @removed = removed - @deprecated_options = deprecated end - attr_reader :deprecated_options - def []=(key, value) key = key.to_sym - if @deprecated_options.key?(key) - warn_deprecated(key) - path = @deprecated_options[key] - return if path.nil? + if @allowed_keys.include?(key) + if self[key].is_a?(Options) + raise TypeError, "#{@namespace}.#{key} must be a Hash" unless value.is_a?(Hash) - path[0...-1].reduce(self) { |options, path_key| options[path_key] }[path.last] = value - elsif @allowed_keys.include?(key) + value = self[key].dup.clear.tap do |options| + value.each_pair { |nested_key, nested_value| options[nested_key] = nested_value } + end + end super else raise UnknownConfigOptionError.new( @@ -48,24 +39,6 @@ def []=(key, value) ) end end - - def [](key) - key = key.to_sym - return super unless @deprecated_options.key?(key) - - warn_deprecated(key) - @deprecated_options[key]&.reduce(self) { |options, path_key| options[path_key] } - end - - private - - def warn_deprecated(key) - path = @deprecated_options[key] - message = "`#{@namespace}.#{key}` is deprecated and will be removed in ViewComponent #{DEPRECATION_HORIZON}." - message += " Use `#{@namespace}.#{path.join(".")}` instead." if path - # Point the warning at the app's config rather than at this file. - ViewComponent::Deprecation.warn(message, caller_locations.drop_while { |location| location.path == __FILE__ }) - end end # Options renamed in 4.0, mapped to their new name. @@ -79,11 +52,6 @@ def warn_deprecated(key) view_component_path: "generate.path" }.freeze - # Generator options renamed in 4.0, mapped to their new name. - RENAMED_GENERATE_OPTIONS = { - component_parent_class: "generate.parent_class" - }.freeze - # Options removed in 4.0, mapped to a hint about what to do instead. REMOVED_OPTIONS = { test_controller: "Use the `vc_test_controller_class` test helper instead.", @@ -254,7 +222,7 @@ def default_generate_options "#{Options::ROOT_NAMESPACE}.generate", %i[path preview_path parent_class sidecar stimulus_controller typescript locale distinct_locale_files preview use_component_path_for_rspec_tests], default: false, - renamed: RENAMED_GENERATE_OPTIONS + renamed: RENAMED_OPTIONS ) options.preview_path = "" options.path = "app/components" diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index 6eac7188f..2cfbd8903 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -103,40 +103,48 @@ def test_generate_options_default_to_false assert_equal false, @config.generate.typescript end - def test_setting_a_deprecated_option_sets_its_replacement - options = deprecated_options + def test_replacing_nested_options_preserves_strictness + %i[previews generate].each do |namespace| + @config[namespace] = ActiveSupport::OrderedOptions.new - assert_deprecated(/`test.nested.new_name` instead/, ViewComponent::Deprecation) do - options.old_name = "value" + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config[namespace].typo = true + end end - assert_equal "value", options.nested.new_name + assert_equal false, @config.generate.typescript end - def test_reading_a_deprecated_option_reads_its_replacement - options = deprecated_options - options.nested.new_name = "value" + def test_replacing_nested_options_preserves_values + @config.previews = {"controller" => "MyPreviewController"} + @config.generate = {parent_class: "MyBaseComponent"} - assert_deprecated(/`test.old_name` is deprecated/, ViewComponent::Deprecation) do - assert_equal "value", options.old_name + assert_equal({controller: "MyPreviewController"}, @config.previews) + assert_equal({parent_class: "MyBaseComponent"}, @config.generate) + assert_equal false, @config.generate.typescript + assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.generate.component_parent_class = "OtherComponent" end end - def test_setting_a_deprecated_removed_option_stores_nothing - options = deprecated_options + def test_replacing_nested_options_rejects_unknown_keys + original = @config.previews + replacement = ActiveSupport::OrderedOptions.new + replacement.contoller = "MyPreviewController" - assert_deprecated(/`test.removed_name` is deprecated/, ViewComponent::Deprecation) do - options.removed_name = "value" + error = assert_raises(ViewComponent::UnknownConfigOptionError) do + @config.previews = replacement end - assert_equal({nested: {}}, options.to_h) - end - def test_no_deprecated_options_remain_past_the_deprecation_horizon - skip if Gem::Version.new(ViewComponent::DEPRECATION_HORIZON).segments.first > ViewComponent::VERSION::MAJOR + assert_includes error.message, "`config.view_component.previews.contoller`" + assert_same original, @config.previews + end - defaults = ViewComponent::Config.defaults - options = [defaults, defaults.generate, defaults.previews] - assert options.all? { |namespace| namespace.deprecated_options.empty? }, - "Move deprecated options to `RENAMED_OPTIONS` or `REMOVED_OPTIONS` so that setting them raises." + def test_replacing_nested_options_rejects_non_hash_values + %i[previews generate].each do |namespace| + original = @config[namespace] + assert_raises(TypeError) { @config[namespace] = nil } + assert_same original, @config[namespace] + end end def test_all_methods_are_documented @@ -159,17 +167,5 @@ def test_all_methods_are_documented assert configuration_methods_to_document.map(&:docstring).all?(&:present?), "Configuration options are missing docstrings." end - - private - - def deprecated_options - options = ViewComponent::Config::Options.new( - "test", - %i[nested], - deprecated: {old_name: %i[nested new_name], removed_name: nil} - ) - options.nested = ViewComponent::Config::Options.new("test.nested", %i[new_name]) - options - end end end From 5dba83cb97abdb77756f3dad4f3d334dac5ccf3c Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 29 Oct 2026 11:09:04 -0600 Subject: [PATCH 3/3] Fix changelog prose lint --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5b0e6c24f..ab2484fdb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,7 +14,7 @@ nav_order: 6 *Joel Hawksley* -* Raise `ViewComponent::UnknownConfigOptionError` when setting a `config.view_component` option that doesn't exist, rather than silently ignoring it. This includes options renamed or removed in 4.0, such as `preview_controller` (now `previews.controller`), and the error names the replacement. Replacing `previews` or `generate` with a hash preserves this validation. +* Raise `ViewComponent::UnknownConfigOptionError` when setting a `config.view_component` option that doesn't exist, rather than ignoring it. This includes options renamed or removed in 4.0, such as `preview_controller` (now `previews.controller`), and the error names the replacement. Replacing `previews` or `generate` with a hash preserves this validation. *Fiona Burcombe*