From b4c7916096158d903e846585e9207ba509605dc3 Mon Sep 17 00:00:00 2001 From: Andrii Furmanets Date: Sat, 17 Oct 2026 10:50:04 +0300 Subject: [PATCH 1/2] Fall back to global translations in Component.translate Calling translate on the component class with a key that isn't in the sidecar translations raised UncaughtThrowError, because the I18n backend throws :exception for a missing key and nothing caught it. Components without sidecar translations raised NoMethodError on the nil backend. Fall back to I18n.translate in both cases, like the instance method falls back to the view context's translate. --- docs/CHANGELOG.md | 4 ++++ lib/view_component/translatable.rb | 11 ++++++++++- test/sandbox/test/translatable_test.rb | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7cf29eb89..a804d2e31 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* Fix `Component.translate` raising `UncaughtThrowError` for keys missing from the sidecar translations, and `NoMethodError` for components without sidecar translations. It now falls back to the global translations, like the instance method does. + + *Andrii Furmanets* + ## 4.16.0 * Fix subclasses defined after their parent renders a collection using the parent's default collection parameter name instead of their own. Fix recompiling a component not refreshing its collection parameter, counter, and iteration detection. Compute that detection when a component compiles, and store the results frozen so they're shareable across Ractors. diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 815a1325e..357890182 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -59,7 +59,16 @@ def translate(key = nil, **options) locale = options.delete(:locale) || ::I18n.locale key = __vc_i18n_key(key, options.delete(:scope)) - __vc_i18n_backend.translate(locale, key, options) + if __vc_i18n_backend + translated = catch(:exception) do + __vc_i18n_backend.translate(locale, key, options) + end + + return translated unless translated.is_a?(::I18n::MissingTranslation) + end + + # Fallback to the global translations + ::I18n.translate(key, locale: locale, **options) end alias_method :t, :translate diff --git a/test/sandbox/test/translatable_test.rb b/test/sandbox/test/translatable_test.rb index bd1cdc0e8..b0a92966e 100644 --- a/test/sandbox/test/translatable_test.rb +++ b/test/sandbox/test/translatable_test.rb @@ -165,6 +165,22 @@ def test_translating_from_the_class assert_equal({sidecar: "This is coming from the sidecar"}, TranslatableComponent.translate(".from")) end + def test_translating_from_the_class_falls_back_to_global_translations + assert_equal "This is coming from Rails", TranslatableComponent.t("from.rails") + assert_equal "Relative key from Rails", TranslatableComponent.t(".relative_rails_key") + assert_equal "Translation missing: en.translatable_component.missing", TranslatableComponent.t(".missing") + assert_equal "Default", TranslatableComponent.t(".missing", default: "Default") + + assert_raises(I18n::MissingTranslationData) do + TranslatableComponent.t(".missing", raise: true) + end + end + + def test_translating_from_the_class_without_sidecar_translations + assert_equal "OH NO! (you shouldn't see me)", MyComponent.t(".message") + assert_equal "This is coming from Rails", MyComponent.translate("from.rails") + end + def test_inheriting_and_overriding_translations render_inline(TranslatableSubclassComponent.new) From 2297b26f342f26229cbeab7c3059e3fed89b041f Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 Oct 2026 12:32:01 -0600 Subject: [PATCH 2/2] Check sidecar key existence instead of catching MissingTranslation Use I18nBackend#exists? to decide between sidecar and global translations in both the class and instance translate methods, rather than catching I18n's internal :exception throw. This also fixes `default:` shadowing existing global translations, since the sidecar backend applied the default before the fallback could run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ba89fbcb-d0e9-4f12-8673-169fbe391979 --- docs/CHANGELOG.md | 2 +- lib/view_component/translatable.rb | 26 ++++++-------------------- test/sandbox/test/translatable_test.rb | 10 ++++++++++ 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a804d2e31..87f81d23f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,7 @@ nav_order: 6 ## main -* Fix `Component.translate` raising `UncaughtThrowError` for keys missing from the sidecar translations, and `NoMethodError` for components without sidecar translations. It now falls back to the global translations, like the instance method does. +* Fix `Component.translate` raising `UncaughtThrowError` for keys missing from the sidecar translations, and `NoMethodError` for components without sidecar translations. It now falls back to the global translations, like the instance method does. Fix `translate` returning the `default:` value instead of an existing global translation. *Andrii Furmanets* diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 357890182..c4d532451 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -59,16 +59,11 @@ def translate(key = nil, **options) locale = options.delete(:locale) || ::I18n.locale key = __vc_i18n_key(key, options.delete(:scope)) - if __vc_i18n_backend - translated = catch(:exception) do - __vc_i18n_backend.translate(locale, key, options) - end - - return translated unless translated.is_a?(::I18n::MissingTranslation) + if __vc_i18n_backend&.exists?(locale, key) + __vc_i18n_backend.translate(locale, key, options) + else + ::I18n.translate(key, locale: locale, **options) end - - # Fallback to the global translations - ::I18n.translate(key, locale: locale, **options) end alias_method :t, :translate @@ -111,17 +106,8 @@ def translate(key = nil, **options) __vc_html_escape_translation_options!(options) if as_html - if key.start_with?(__vc_i18n_scope + ".") - translated = - catch(:exception) do - __vc_i18n_backend.translate(locale, key, options) - end - - # Fallback to the global translations - if translated.is_a? ::I18n::MissingTranslation - return @view_context.translate(key, locale: locale, **options) - end - + if __vc_i18n_backend.exists?(locale, key) + translated = __vc_i18n_backend.translate(locale, key, options) translated = __vc_html_safe_translation(translated) if as_html translated else diff --git a/test/sandbox/test/translatable_test.rb b/test/sandbox/test/translatable_test.rb index b0a92966e..806cf1caa 100644 --- a/test/sandbox/test/translatable_test.rb +++ b/test/sandbox/test/translatable_test.rb @@ -181,6 +181,16 @@ def test_translating_from_the_class_without_sidecar_translations assert_equal "This is coming from Rails", MyComponent.translate("from.rails") end + def test_default_does_not_shadow_global_translations + assert_equal "This is coming from Rails", TranslatableComponent.t("from.rails", default: "Default") + assert_equal "Relative key from Rails", TranslatableComponent.t(".relative_rails_key", default: "Default") + assert_equal "Default", TranslatableComponent.t(".missing", default: "Default") + + assert_equal "This is coming from Rails", translate("from.rails", default: "Default") + assert_equal "Relative key from Rails", translate(".relative_rails_key", default: "Default") + assert_equal "Default", translate(".missing", default: "Default") + end + def test_inheriting_and_overriding_translations render_inline(TranslatableSubclassComponent.new)