Module: ViewComponent::CacheDigest

Extended by:
ActiveSupport::Autoload
Defined in:
lib/view_component/cache_digest.rb,
lib/view_component/cache_digest/resolver.rb,
lib/view_component/cache_digest/dependency_tracking.rb

Overview

Integrates ViewComponents into Rails' template digest tree.

Rails computes a digest for every template from its source and the templates it renders. That digest is mixed into the key of every <% cache %> block in the template, so editing a partial busts the caches of everything that renders it.

Components are invisible to that mechanism for two reasons:

  1. DiscoveryActionView::DependencyTracker doesn't recognize render SomeComponent.new(...) as a dependency.
  2. Resolution — component templates live outside the view paths, and a component's rendered output depends on its Ruby class and sidecar files, not just its template.

This module fixes both, reusing Rails' own ActionView::Digestor rather than reimplementing static analysis. Components opt in individually by including ViewComponent::ExperimentallyCacheable; until at least one component does, every hook here short-circuits.

Defined Under Namespace

Modules: DependencyTracking Classes: Resolver

Constant Summary collapse

VIRTUAL_PATH_PREFIX =

Prefix for the synthetic virtual paths components are digested under.

Namespaced under view_component/ so it can't collide with an application partial.

"view_component/cache_digest"
RENDER_CALL =

Matches render FooComponent, render(Foo::BarComponent.new(...)), render FooComponent.with_collection(...), etc.

Deliberately a plain source scan rather than a tracker-specific hook: it behaves identically for the ERB tracker, the Prism-based Ruby tracker, and third-party Haml/Slim trackers.

/
  \brender(?:_to_string)?\b   # render or render_to_string
  \s*\(?\s*                   # optional opening paren
  (?<const>
    (?:::)?[A-Z]\w*           # a constant
    (?:::[A-Z]\w*)*           # optionally namespaced
  )
/x
EXPLICIT_DEPENDENCY =

Rails' escape hatch for dependencies static analysis can't see.

/#\s*Template Dependency:\s*(\S+)/
RENDER_PARSER =

Resolved once at load time rather than memoized, so no class-level state is written after boot.

resolve_render_parser(ActionView::RenderParser)

Class Method Summary collapse

Class Method Details

.component_for(virtual_path) ⇒ Class?

Resolve a synthetic virtual path back to the component that owns it.

Returns:

  • (Class, nil)


94
95
96
97
98
99
100
101
# File 'lib/view_component/cache_digest.rb', line 94

def component_for(virtual_path)
  return unless virtual_path.start_with?("#{VIRTUAL_PATH_PREFIX}/")

  name = registry[virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")]
  return unless name

  constantize_component(name)
end

.component_paths_in(source) ⇒ Array<String>

Scan arbitrary source (a template or a component's Ruby file) for renders of cacheable components.

Returns:

  • (Array<String>)

    synthetic virtual paths



119
120
121
122
123
124
125
126
# File 'lib/view_component/cache_digest.rb', line 119

def component_paths_in(source)
  return [] unless source.is_a?(String) && source.include?("render")

  source.scan(RENDER_CALL).flatten.uniq.filter_map do |constant_name|
    component = constantize_component(constant_name)
    virtual_path_for(component) if component
  end
end

.default_finderActionView::LookupContext

A lookup context for digesting components outside a request, where no view context (and therefore no finder) exists.

Returns:

  • (ActionView::LookupContext)


201
202
203
204
# File 'lib/view_component/cache_digest.rb', line 201

def default_finder
  # Not memoized across reloads: view paths change when the app reloads.
  ActionView::LookupContext.new(ActionController::Base.view_paths)
end

.dependencies_in(template) ⇒ Array<String>

Scan a template's source for renders of cacheable components.

Called for every template Rails digests, so it exits early when the feature is unused.

Returns:

  • (Array<String>)

    synthetic virtual paths



109
110
111
112
113
# File 'lib/view_component/cache_digest.rb', line 109

def dependencies_in(template)
  return [] unless enabled?

  component_paths_in(template.source)
end

.digest(component, finder: default_finder, format: :html) ⇒ String

Compute the digest of a component using Rails' digest tree.

Parameters:

  • component (Class)

    a component that includes ExperimentallyCacheable

  • finder (ActionView::LookupContext) (defaults to: default_finder)
  • format (Symbol) (defaults to: :html)

Returns:

  • (String)


190
191
192
193
194
195
# File 'lib/view_component/cache_digest.rb', line 190

def digest(component, finder: default_finder, format: :html)
  virtual_path = virtual_path_for(component)
  return "" unless virtual_path

  ActionView::Digestor.digest(name: virtual_path, format: format, finder: finder)
end

.enabled?Boolean

Returns whether any component has opted in.

Returns:

  • (Boolean)

    whether any component has opted in.



71
72
73
# File 'lib/view_component/cache_digest.rb', line 71

def enabled?
  !registry.empty?
end

.explicit_component_dependencies(source) ⇒ Array<Array(String, String)>

Resolve # Template Dependency: SomeComponent declarations.

Rails' escape hatch takes a template path, but the path a component is digested under is an internal detail. Naming the class instead keeps that detail out of application code, so SomeComponent is translated to the path the Digestor can resolve.

Returns:

  • (Array<Array(String, String)>)

    pairs of declared name and synthetic virtual path



173
174
175
176
177
178
179
180
181
182
# File 'lib/view_component/cache_digest.rb', line 173

def explicit_component_dependencies(source)
  return [] unless source.is_a?(String) && source.include?("Template Dependency:")

  source.scan(EXPLICIT_DEPENDENCY).flatten.uniq.filter_map do |declared|
    next unless /\A(?:::)?[A-Z]/.match?(declared)

    component = constantize_component(declared)
    [declared, virtual_path_for(component)] if component
  end
end

.install!Object

Wire the tracker and resolver into Action View.

Called each time a component includes ExperimentallyCacheable. Both steps below are individually idempotent, so no "already installed" flag is kept. Both hooks short-circuit while the registry is empty, so applications that never opt in are unaffected.



214
215
216
217
218
219
220
221
222
# File 'lib/view_component/cache_digest.rb', line 214

def install!
  DependencyTracking.install!

  ActiveSupport.on_load(:action_controller_base) do
    resolver = ViewComponent::CacheDigest::Resolver.instance

    append_view_path(resolver) unless view_paths.include?(resolver)
  end
end

.partial_paths_in(source, name) ⇒ Array<String>

Scan a component's Ruby source for partials referenced by string path, such as render "posts/byline" inside a #call method.

Uses Rails' own render parser — the same one RubyTracker runs over compiled templates — rather than a second implementation of the same analysis. Its results are then narrowed to paths that appear verbatim in the source, which keeps string literals and discards the speculative things/_thing entries the parser infers from dynamic renders like render @thing or render FooComponent.new. Those would resolve to nothing and only add log noise; components rendered from Ruby are already found precisely by component_paths_in.

Parameters:

  • source (String)

    Ruby source

  • name (String)

    virtual path the source is being digested under

Returns:

  • (Array<String>)

    partial virtual paths



143
144
145
146
147
148
149
150
151
152
# File 'lib/view_component/cache_digest.rb', line 143

def partial_paths_in(source, name)
  return [] unless source.is_a?(String) && source.include?("render")

  RENDER_PARSER.new(name, source).render_calls.uniq.select do |path|
    source.include?(path) || source.include?(path.sub(%r{(\A|/)_}, '\1'))
  end
rescue
  # Never let digest computation break rendering.
  []
end

.register(component) ⇒ Object



76
77
78
79
80
# File 'lib/view_component/cache_digest.rb', line 76

def register(component)
  return unless component.virtual_path && component.name

  registry[component.virtual_path] = component.name
end

.registryHash{String => String}

Virtual paths of components that have opted into caching, mapped to their class names.

Class names rather than class objects so the registry survives autoloader reloads without pinning stale constants in memory.

Returns:

  • (Hash{String => String})


66
67
68
# File 'lib/view_component/cache_digest.rb', line 66

def registry
  @registry ||= {}
end

.resolve_render_parser(parser) ⇒ Class

Action View has shipped its render parser as a class (Rails 7.1, and again on main) and as a module holding a Default implementation chosen from Prism or Ripper (Rails 7.2 through 8.1).

Parameters:

  • parser (Class, Module)

    ActionView::RenderParser

Returns:

  • (Class)


160
161
162
# File 'lib/view_component/cache_digest.rb', line 160

def resolve_render_parser(parser)
  parser.is_a?(Class) ? parser : parser::Default
end

.virtual_path_for(component) ⇒ String?

The synthetic virtual path a component is digested under.

Returns:

  • (String, nil)


85
86
87
88
89
# File 'lib/view_component/cache_digest.rb', line 85

def virtual_path_for(component)
  return unless component.respond_to?(:virtual_path) && component.virtual_path

  "#{VIRTUAL_PATH_PREFIX}/#{component.virtual_path}"
end