Skip to content

Commit 1c1c201

Browse files
committed
Report the errors swallowed while computing a cache digest
Every rescue in the digest machinery returns a neutral value, so a misconfiguration, an autoload failure, or a raising `inherited` hook degrades to "no component dependencies" and the application serves stale HTML with nothing reported anywhere. - Route the four swallow sites through CacheDigest.handle_error - Log at `warn` through ActiveSupport's logger, preserving the production guarantee that a stale fragment beats a failed render - Raise instead in local environments, configurable with config.view_component.raise_on_cache_digest_errors
1 parent 2e6e02d commit 1c1c201

10 files changed

Lines changed: 193 additions & 26 deletions

File tree

‎docs/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ nav_order: 6
1010

1111
## main
1212

13+
* Report the errors swallowed while computing a component's cache digest, instead of degrading to an untracked component with no indication that anything went wrong. Digest errors are now raised in local environments and logged at `warn` elsewhere, configurable with `config.view_component.raise_on_cache_digest_errors`.
14+
15+
*Erik Axel Nielsen*
16+
1317
## 4.15.0
1418

1519
* Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`.

‎docs/api.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,19 @@ A custom default layout used for the previews index page and individual previews
294294

295295
config.view_component.previews.default_layout = "preview_layout"
296296

297+
### `.raise_on_cache_digest_errors`
298+
299+
Whether to raise when computing a component's cache digest fails.
300+
301+
Digest failures are otherwise swallowed, since a stale fragment is
302+
preferable to a failed render, and reported to the log at `warn`. That
303+
trade is wrong in development and test, where an untracked component
304+
looks exactly like a component that was never cached.
305+
306+
Defaults to `true` in local environments and `false` elsewhere:
307+
308+
config.view_component.raise_on_cache_digest_errors = false
309+
297310
## ViewComponent::TestHelpers
298311

299312
### `#render_in_view_context(...)`

‎docs/guide/caching.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,22 @@ The same works in a template, where the branch is often the more natural place f
189189

190190
Declared components must include `ViewComponent::ExperimentallyCacheable` themselves, since a component that hasn't opted in has no digest to depend on.
191191

192+
## When a digest can't be computed
193+
194+
Computing a digest touches the autoloader, the filesystem, and Action View's dependency trackers, any of which can fail. In production those failures are swallowed: a component that can't be digested is left untracked, which is exactly the behavior it had before opting in, and a stale fragment beats a failed render.
195+
196+
That trade is wrong while developing, where an untracked component is indistinguishable from a component that was never cached in the first place. Digest failures are therefore raised in local environments and logged at `warn` everywhere else:
197+
198+
```console
199+
[ViewComponent] Ignored an error while resolving PostComponent: NoMethodError: ...
200+
```
201+
202+
To swallow them locally too, or to raise them in production:
203+
204+
```ruby
205+
config.view_component.raise_on_cache_digest_errors = false
206+
```
207+
192208
## Caveats
193209

194210
**Self-caching components can't take content from their callers.** Besides a block, this covers `with_content` and slots set by the caller:

‎lib/view_component/cache_digest.rb‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "active_support/dependencies/autoload"
4+
require "active_support/log_subscriber"
45
require "action_view/digestor"
56
require "action_view/render_parser"
67

@@ -146,8 +147,8 @@ def partial_paths_in(source, name)
146147
RENDER_PARSER.new(name, source).render_calls.uniq.select do |path|
147148
source.include?(path) || source.include?(path.sub(%r{(\A|/)_}, '\1'))
148149
end
149-
rescue
150-
# Never let digest computation break rendering.
150+
rescue => error
151+
handle_error(error, "scanning #{name} for rendered partials")
151152
[]
152153
end
153154

@@ -221,6 +222,30 @@ def install!
221222
end
222223
end
223224

225+
# Report an exception the digest machinery swallowed.
226+
#
227+
# Every rescue here degrades to "this component has no dependencies",
228+
# which is indistinguishable from a component that was never cached: the
229+
# digest stops changing and the application serves stale HTML. A
230+
# misconfiguration, an autoload failure, or a raising `inherited` hook is
231+
# therefore completely invisible.
232+
#
233+
# In production a stale fragment beats a failed render, so the exception
234+
# is only logged. Locally the trade goes the other way, so it's re-raised
235+
# by default; see `config.view_component.raise_on_cache_digest_errors`.
236+
#
237+
# @private
238+
def handle_error(error, context)
239+
raise error if ViewComponent::Base.config.raise_on_cache_digest_errors
240+
241+
logger&.warn { "[ViewComponent] Ignored an error while #{context}: #{error.class}: #{error.message}" }
242+
end
243+
244+
# @return [ActiveSupport::BroadcastLogger, Logger, nil] nil outside a Rails application.
245+
def logger
246+
ActiveSupport::LogSubscriber.logger
247+
end
248+
224249
private
225250

226251
# Resolve a constant name to a component that opted into caching.
@@ -234,8 +259,8 @@ def constantize_component(constant_name)
234259
return unless component.respond_to?(:__vc_cacheable?) && component.__vc_cacheable?
235260

236261
component
237-
rescue
238-
# Never let digest computation break rendering.
262+
rescue => error
263+
handle_error(error, "resolving #{constant_name}")
239264
nil
240265
end
241266
end

‎lib/view_component/cache_digest/dependency_tracking.rb‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ def find_dependencies(name, template, view_paths = nil)
2727
end
2828

2929
dependencies + CacheDigest.dependencies_in(template)
30-
rescue
31-
# A broken digest is preferable to a broken render. Falling back to the
32-
# dependencies Rails found on its own means the component simply isn't
33-
# tracked, which is the pre-existing behavior.
30+
rescue => error
31+
# Falling back to the dependencies Rails found on its own means the
32+
# component simply isn't tracked, which is the pre-existing behavior.
33+
CacheDigest.handle_error(error, "tracking component dependencies in #{name}")
3434
super
3535
end
3636

‎lib/view_component/cache_digest/resolver.rb‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ def find_templates(name, prefix, partial, details, locals = [])
3030
return [] unless component
3131

3232
[build_template(component, virtual_path, details)]
33-
rescue
34-
# Never let digest resolution break rendering. Returning no template
35-
# makes the Digestor treat this as a missing node, which degrades to
36-
# the behavior components have without this feature.
33+
rescue => error
34+
# Returning no template makes the Digestor treat this as a missing
35+
# node, which degrades to the behavior components have without this
36+
# feature.
37+
CacheDigest.handle_error(error, "building the digest template for #{virtual_path || name}")
3738
[]
3839
end
3940

‎lib/view_component/config.rb‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ def defaults
1414
ActiveSupport::OrderedOptions.new.merge!({
1515
generate: default_generate_options,
1616
previews: default_previews_options,
17-
instrumentation_enabled: false
17+
instrumentation_enabled: false,
18+
raise_on_cache_digest_errors: default_raise_on_cache_digest_errors
1819
})
1920
end
2021

@@ -131,6 +132,20 @@ def defaults
131132
# Whether ActiveSupport notifications are enabled.
132133
# Defaults to `false`.
133134

135+
# @!attribute raise_on_cache_digest_errors
136+
#
137+
# @return [Boolean]
138+
# Whether to raise when computing a component's cache digest fails.
139+
#
140+
# Digest failures are otherwise swallowed, since a stale fragment is
141+
# preferable to a failed render, and reported to the log at `warn`. That
142+
# trade is wrong in development and test, where an untracked component
143+
# looks exactly like a component that was never cached.
144+
#
145+
# Defaults to `true` in local environments and `false` elsewhere:
146+
#
147+
# config.view_component.raise_on_cache_digest_errors = false
148+
134149
def default_preview_paths
135150
(default_rails_preview_paths + default_rails_engines_preview_paths).uniq
136151
end
@@ -155,6 +170,10 @@ def registered_rails_engines_with_previews
155170
end
156171
end
157172

173+
def default_raise_on_cache_digest_errors
174+
defined?(Rails.env) && Rails.env.local?
175+
end
176+
158177
def default_generate_options
159178
options = ActiveSupport::OrderedOptions.new(false)
160179
options.preview_path = ""

‎lib/view_component/engine.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class Engine < Rails::Engine # :nodoc:
1515
options[config_option] ||= ViewComponent::Base.public_send(config_option)
1616
end
1717
options.instrumentation_enabled = false if options.instrumentation_enabled.nil?
18+
options.raise_on_cache_digest_errors = Rails.env.local? if options.raise_on_cache_digest_errors.nil?
1819
options.previews.enabled = (Rails.env.development? || Rails.env.test?) if options.previews.enabled.nil?
1920

2021
if options.previews.enabled

‎test/sandbox/test/config_test.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def test_defaults_are_correct
1313
assert_equal @config.previews.controller, "ViewComponentsController"
1414
assert_equal @config.previews.route, "/rails/view_components"
1515
assert_equal @config.instrumentation_enabled, false
16+
assert_equal @config.raise_on_cache_digest_errors, Rails.env.local?
1617
assert_equal @config.previews.paths, ["#{Rails.root}/test/components/previews"]
1718
end
1819

‎test/sandbox/test/experimentally_cacheable_test.rb‎

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,22 @@ def test_partial_paths_are_not_extracted_from_sources_without_render
204204
end
205205

206206
def test_partial_path_extraction_swallows_parser_errors
207+
swallowing_digest_errors do |log|
208+
ViewComponent::CacheDigest::RENDER_PARSER.stub(:new, ->(*) { raise "boom" }) do
209+
assert_empty ViewComponent::CacheDigest.partial_paths_in("render \"a/b\"", "a/b")
210+
end
211+
212+
assert_match "Ignored an error while scanning a/b for rendered partials: RuntimeError: boom", log.string
213+
end
214+
end
215+
216+
def test_partial_path_extraction_raises_parser_errors_locally
207217
ViewComponent::CacheDigest::RENDER_PARSER.stub(:new, ->(*) { raise "boom" }) do
208-
assert_empty ViewComponent::CacheDigest.partial_paths_in("render \"a/b\"", "a/b")
218+
error = assert_raises(RuntimeError) do
219+
ViewComponent::CacheDigest.partial_paths_in("render \"a/b\"", "a/b")
220+
end
221+
222+
assert_equal "boom", error.message
209223
end
210224
end
211225

@@ -437,32 +451,74 @@ def test_resolver_is_identified_by_class
437451
def test_resolver_returns_no_template_when_synthesis_fails
438452
resolver = ViewComponent::CacheDigest::Resolver.instance
439453

454+
swallowing_digest_errors do |log|
455+
ViewComponent::CacheDigest.stub(:component_for, ->(_) { raise "boom" }) do
456+
assert_empty resolver.find_templates("cacheable_component", "view_component/cache_digest", true, {})
457+
end
458+
459+
assert_match(
460+
"Ignored an error while building the digest template for " \
461+
"view_component/cache_digest/cacheable_component: RuntimeError: boom",
462+
log.string
463+
)
464+
end
465+
end
466+
467+
def test_resolver_raises_when_synthesis_fails_locally
468+
resolver = ViewComponent::CacheDigest::Resolver.instance
469+
440470
ViewComponent::CacheDigest.stub(:component_for, ->(_) { raise "boom" }) do
441-
assert_empty resolver.find_templates("cacheable_component", "view_component/cache_digest", true, {})
471+
assert_raises(RuntimeError) do
472+
resolver.find_templates("cacheable_component", "view_component/cache_digest", true, {})
473+
end
442474
end
443475
end
444476

445477
def test_dependency_tracking_falls_back_when_scanning_fails
446478
template = build_template("<%= render CacheableComponent.new(title: 'a') %>")
447479

480+
swallowing_digest_errors do |log|
481+
ViewComponent::CacheDigest.stub(:dependencies_in, ->(_) { raise "boom" }) do
482+
refute_includes(
483+
ActionView::DependencyTracker.find_dependencies("some/template", template, []),
484+
"view_component/cache_digest/cacheable_component"
485+
)
486+
end
487+
488+
assert_match "Ignored an error while tracking component dependencies in some/template", log.string
489+
end
490+
end
491+
492+
def test_dependency_tracking_raises_when_scanning_fails_locally
493+
template = build_template("<%= render CacheableComponent.new(title: 'a') %>")
494+
448495
ViewComponent::CacheDigest.stub(:dependencies_in, ->(_) { raise "boom" }) do
449-
refute_includes(
450-
ActionView::DependencyTracker.find_dependencies("some/template", template, []),
451-
"view_component/cache_digest/cacheable_component"
452-
)
496+
assert_raises(RuntimeError) { ActionView::DependencyTracker.find_dependencies("some/template", template, []) }
453497
end
454498
end
455499

456500
def test_constantizing_swallows_unexpected_errors
457-
Object.const_set(:BoomComponent, Class.new do
458-
def self.__vc_cacheable?
459-
raise ArgumentError
501+
with_boom_component do
502+
swallowing_digest_errors do |log|
503+
assert_nil ViewComponent::CacheDigest.send(:constantize_component, "BoomComponent")
504+
505+
assert_match "Ignored an error while resolving BoomComponent: ArgumentError", log.string
460506
end
461-
end)
507+
end
508+
end
462509

463-
assert_nil ViewComponent::CacheDigest.send(:constantize_component, "BoomComponent")
464-
ensure
465-
Object.send(:remove_const, :BoomComponent)
510+
def test_constantizing_raises_unexpected_errors_locally
511+
with_boom_component do
512+
assert_raises(ArgumentError) { ViewComponent::CacheDigest.send(:constantize_component, "BoomComponent") }
513+
end
514+
end
515+
516+
def test_digest_errors_are_swallowed_without_a_logger
517+
without_raising_digest_errors do
518+
ViewComponent::CacheDigest.stub(:logger, nil) do
519+
assert_nil ViewComponent::CacheDigest.handle_error(RuntimeError.new("boom"), "digesting")
520+
end
521+
end
466522
end
467523

468524
def test_install_is_idempotent
@@ -487,6 +543,37 @@ def recompile(component)
487543
component.__vc_compile(force: true)
488544
end
489545

546+
# The digest machinery raises in local environments, and the sandbox runs as
547+
# `test`, so the swallow-and-report path has to be opted into explicitly.
548+
def without_raising_digest_errors
549+
previous = ViewComponent::Base.config.raise_on_cache_digest_errors
550+
ViewComponent::Base.config.raise_on_cache_digest_errors = false
551+
552+
yield
553+
ensure
554+
ViewComponent::Base.config.raise_on_cache_digest_errors = previous
555+
end
556+
557+
def swallowing_digest_errors
558+
log = StringIO.new
559+
560+
without_raising_digest_errors do
561+
ViewComponent::CacheDigest.stub(:logger, ActiveSupport::Logger.new(log)) { yield log }
562+
end
563+
end
564+
565+
def with_boom_component
566+
Object.const_set(:BoomComponent, Class.new do
567+
def self.__vc_cacheable?
568+
raise ArgumentError
569+
end
570+
end)
571+
572+
yield
573+
ensure
574+
Object.send(:remove_const, :BoomComponent)
575+
end
576+
490577
def build_template(source)
491578
ActionView::Template.new(
492579
source,

0 commit comments

Comments
 (0)