Skip to content

Commit 06e86fc

Browse files
joelhawksleyCopilot
andcommitted
Merge origin/main into vc-digest-report-swallowed-errors
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2 parents 1c1c201 + ab33cb6 commit 06e86fc

30 files changed

Lines changed: 427 additions & 70 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
working-directory: 'view_component'
9696
- uses: actions/setup-node@v5
9797
with:
98-
node-version: 20
98+
node-version: 22
9999
cache: 'npm'
100100
cache-dependency-path: 'primer_view_components/package-lock.json'
101101
- name: Build and test with Rake

‎.github/workflows/lint.yml‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,8 @@ jobs:
126126
uses: ruby/setup-ruby@v1
127127
with:
128128
ruby-version: 4.0
129-
# audition needs Ruby 4.0+; installed standalone since rubydex is a
130-
# precompiled native gem. Fails only on findings not in
131-
# .audition-baseline.json. Static-only skips Ractor issues in dependencies.
132-
- name: Install audition
133-
run: gem install audition
129+
bundler-cache: true
130+
# Fails only on findings not in .audition-baseline.json. Static-only skips
131+
# Ractor issues in dependencies.
134132
- name: Audition (Ractor readiness)
135-
run: audition . --static-only --plain
133+
run: bundle exec audition . --static-only --plain

‎docs/CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ nav_order: 6
1414

1515
*Erik Axel Nielsen*
1616

17+
* Give `<% cache %>` blocks inside a component's own template a digest, for components that `include ViewComponent::ExperimentallyCacheable`.
18+
19+
Rails digests the virtual path of whichever template is rendering. Inside a component that path resolves to no template, because component templates aren't in the view paths, so the Digestor returned an empty digest and the fragment was never invalidated. The only signal was a `Couldn't find template for digesting` line in the log. 4.15.0 fixed the case where the `cache` block wraps the component in a view. This fixes the case where the block sits in the component's template.
20+
21+
*Erik Axel Nielsen*
22+
23+
* Fix line numbers and, under coverage, missing output for ERB templates that Rails doesn't annotate.
24+
25+
On Rails 8.1+, ViewComponent compensated for the newline Rails adds to compiled ERB output ([rails/rails#53731](https://github.com/rails/rails/pull/53731)) for every ERB template, whether from a file or from `erb_template`. That newline lives inside the `<!-- BEGIN ... -->` annotation, which Rails only emits when `annotate_rendered_view_with_filenames` is enabled *and* the template's format is HTML. Compensating unconditionally shifted backtraces for non-HTML templates (`.text.erb`, `.css.erb`) and for every ERB template when annotations are disabled, such as in production, by one line. When coverage was running, the same mismatch made the annotation-stripping workaround remove real template source, so non-HTML templates rendered empty.
26+
27+
Inline templates now decide the compensation when they compile rather than when the component class is defined, matching file templates.
28+
29+
*Ryutaro Mizokami*
30+
31+
* Reduce per-render allocations. Inline renders drop 2 to 3 allocations and collection renders drop 4 to 8 depending on Rails/Ruby version by caching the instrumentation-enabled flag at the module level, memoizing the empty-details `Requested` per `LookupContext`, hoisting per-item metadata lookups out of the collection render loop, and dropping a few gratuitous `**` splats on the `Collection` API boundary.
32+
33+
*Joel Hawksley*
34+
1735
## 4.15.0
1836

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

‎docs/best_practices.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ For example, `User::AvatarComponent` accepts a `User` ActiveRecord object and re
4242

4343
### Extract general-purpose ViewComponents
4444

45-
"Good frameworks are extracted, not invented" - [DHH](https://dhh.dk/arc/000416.html)
46-
4745
Just as ViewComponent itself was extracted from GitHub.com, general-purpose components are best extracted once they've proven helpful across more than one area:
4846

4947
1. Single use-case component implemented.

‎docs/guide/caching.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ end
4242

4343
That's all that's needed for the `<% cache %>` block above to work. The component is registered with Rails' digest tree, and the fragment is invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change, including components and partials rendered from an inline template or a `#call` method.
4444

45+
## Caching inside a component template
46+
47+
A `<% cache %>` block written inside a component's own template has the same problem, for the same reason: Rails digests the template that's rendering, and a component's template isn't in the view paths, so there's nothing to digest.
48+
49+
```erb
50+
<%# app/components/post_component.html.erb %>
51+
<% cache @post do %>
52+
<%= render CommentComponent.new(post: @post) %>
53+
<% end %>
54+
```
55+
56+
Including the module fixes this too. The component's own digest is substituted for the empty one Rails computes, so the fragment is invalidated by the same set of changes listed above. A component that hasn't opted in gets no digest at all, and the fragment is never invalidated.
57+
58+
`cache` blocks in partials the component renders are unaffected: those templates resolve through the view paths like any other, so Rails digests them itself.
59+
4560
## Self-caching
4661

4762
To have a component cache its own output without needing a `cache` block, use `cache_on` to declare methods used for the component's cache key.

‎lib/view_component/base.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ def sidecar_files(extensions)
631631
# @param spacer_component [ViewComponent::Base] Component instance to be rendered between items.
632632
# @param args [Arguments] Arguments to pass to the ViewComponent every time.
633633
def with_collection(collection, spacer_component: nil, **args)
634-
Collection.new(self, collection, spacer_component, **args)
634+
Collection.new(self, collection, spacer_component, args)
635635
end
636636

637637
# @private

‎lib/view_component/collection.rb‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class Collection
1212

1313
delegate :size, to: :@collection
1414

15+
EMPTY_SPACER = "".html_safe.freeze
16+
private_constant :EMPTY_SPACER
17+
1518
def render_in(view_context, **_, &block)
1619
rendered = components.map! do |component|
1720
component.render_in(view_context, &block)
@@ -36,18 +39,27 @@ def format
3639
# Always rebuild child component instances per render to avoid leaking
3740
# request-scoped state from a previous render into a later one (GHSA).
3841
def components
39-
iterator = ActionView::PartialIteration.new(@collection.size)
40-
4142
component.__vc_validate_collection_parameter!(validate_default: true)
4243

44+
iterator = ActionView::PartialIteration.new(@collection.size)
45+
collection_param = component.__vc_collection_parameter
46+
counter_present = component.__vc_counter_argument_present?
47+
counter_param = component.__vc_collection_counter_parameter if counter_present
48+
iteration_present = component.__vc_iteration_argument_present?
49+
iteration_param = component.__vc_collection_iteration_parameter if iteration_present
50+
item_options = @options.dup
51+
4352
@collection.map do |item|
44-
component.new(**component_options(item, iterator)).tap do |_|
45-
iterator.iterate!
46-
end
53+
item_options[collection_param] = item
54+
item_options[counter_param] = iterator.index if counter_present
55+
item_options[iteration_param] = iterator.dup if iteration_present
56+
instance = component.new(**item_options)
57+
iterator.iterate!
58+
instance
4759
end
4860
end
4961

50-
def initialize(component, object, spacer_component, **options)
62+
def initialize(component, object, spacer_component, options = {})
5163
@component = component
5264
@collection = collection_variable(object || [])
5365
@spacer_component = spacer_component
@@ -62,20 +74,11 @@ def collection_variable(object)
6274
end
6375
end
6476

65-
def component_options(item, iterator)
66-
item_options = @options.dup
67-
item_options[component.__vc_collection_parameter] = item
68-
item_options[component.__vc_collection_counter_parameter] = iterator.index if component.__vc_counter_argument_present?
69-
item_options[component.__vc_collection_iteration_parameter] = iterator.dup if component.__vc_iteration_argument_present?
70-
71-
item_options
72-
end
73-
7477
# Render the spacer through a fresh `dup` so a collection rendered multiple
7578
# times does not reuse (and trip the single-render guard on) the spacer
7679
# instance passed by the caller.
7780
def rendered_spacer(view_context)
78-
return "" unless @spacer_component
81+
return EMPTY_SPACER unless @spacer_component
7982

8083
spacer = @spacer_component.dup
8184
if spacer.instance_variable_defined?(:@__vc_rendered)

‎lib/view_component/engine.rb‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Engine < Rails::Engine # :nodoc:
2929
initializer "view_component.enable_instrumentation" do |app|
3030
ActiveSupport.on_load(:view_component) do
3131
if app.config.view_component.instrumentation_enabled.present?
32+
ViewComponent::Instrumentation.enabled = true
3233
ViewComponent::Base.prepend(ViewComponent::Instrumentation)
3334
end
3435
end

‎lib/view_component/experimentally_cacheable.rb‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ module ViewComponent
1212
# Including this module does two things:
1313
#
1414
# 1. Registers the component with Rails' template digest tree, so a
15-
# `<% cache %>` block wrapping the component in a view is invalidated when
16-
# the component's template, Ruby class, sidecar files, or child components
17-
# change.
15+
# `<% cache %>` block is invalidated when the component's template, Ruby
16+
# class, sidecar files, or child components change. This covers blocks
17+
# wrapping the component in a view and blocks inside the component's own
18+
# template.
1819
# 2. Enables the `cache_on` macro, which caches the component's own rendered
1920
# output.
2021
#
@@ -225,6 +226,33 @@ def cache_key(view_context = nil)
225226
)
226227
end
227228

229+
# The digest Rails mixes into the key of a `<% cache %>` block.
230+
#
231+
# `ActionView::Helpers::CacheHelper` digests the virtual path of whichever
232+
# template is rendering. Inside a component that path is the component's
233+
# own, which resolves to nothing: component templates aren't in the view
234+
# paths. The Digestor returns an empty digest, `CacheHelper` falls back to
235+
# the bare virtual path, and the fragment never invalidates.
236+
#
237+
# Substituting the digest the component already computes makes a `cache`
238+
# block in a component template behave like one in a view. Everything else
239+
# the component renders — a partial, say — keeps Rails' behavior.
240+
#
241+
# @private
242+
def digest_path_from_template(template)
243+
component_path = self.class.virtual_path
244+
return super unless component_path && template.virtual_path == component_path
245+
246+
digest = self.class.cache_digest(
247+
finder: lookup_context,
248+
format: template.format || __vc_cache_format(lookup_context)
249+
)
250+
251+
# An empty digest means the component couldn't be resolved. Falling back
252+
# to the bare virtual path matches what `CacheHelper` does with one.
253+
digest.present? ? "#{component_path}:#{digest}" : component_path
254+
end
255+
228256
private
229257

230258
# Slots set by the caller via `with_*`. Checked before rendering, so slots

‎lib/view_component/instrumentation.rb‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ def self.included(mod)
88
mod.prepend(self) unless self <= ViewComponent::Instrumentation
99
end
1010

11+
class << self
12+
attr_accessor :enabled
13+
end
14+
1115
def render_in(...)
12-
return super if !Rails.application.config.view_component.instrumentation_enabled.present?
16+
return super unless Instrumentation.enabled
1317

1418
payload = {
1519
name: self.class.name,

0 commit comments

Comments
 (0)