view_component 4.14.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83cf2f7914c64ec4ca156f19d975d79d311619024fb550fde59c0e6f807ca02c
4
- data.tar.gz: 918cc12667440e6a50793addc531431050e06bda525f348c8522487ea0208439
3
+ metadata.gz: 6b893658a7831510ca06effa1b9abb1d50669a6910c31049f4225a164987e858
4
+ data.tar.gz: c4e55c9e7f4e49117c8abbaeaafb077c535be72d3b78775283e8599110cd47aa
5
5
  SHA512:
6
- metadata.gz: fa5696a0ca7c945fa3ed682fb8f679fd75c462ddb0651b8926e64448392507a5616dcee97218e0a74c3eb4f4d384a89ff2f26fd08d89c9ff86ca961fd6cd410d
7
- data.tar.gz: f1ced2760c93c93d46e2ed508ec234abbe498100ccb80d1ef83f6d7c1b17e79b7ecbd44f26745e36a7fef74426da49867bbdef038885b954b04982d788e4c322
6
+ metadata.gz: f4d1ad504f345a9eff5088d22f34955d9a0d95f6990d16923e373f264c189ed8871a8551ec039985650251e330f22c752d7cb04a8bc1e0ddfc40dfde42148947
7
+ data.tar.gz: 05a6b4ace5d3bbb6fede8ea9f8d93a1291d1222f998d8cce3158133167430c06d5371cc2fdf7794dc2e432bde2ec650a0edeccea0465eabfb2dad6589289acd2
@@ -11,6 +11,7 @@ class ViewComponentsSystemTestController < ActionController::Base # :nodoc:
11
11
  end
12
12
 
13
13
  rescue_from ViewComponent::SystemTestControllerNefariousPathError, with: :render_not_found
14
+ rescue_from Errno::ENOENT, with: :render_not_found
14
15
 
15
16
  def system_test_entrypoint
16
17
  render file: @path
data/docs/CHANGELOG.md CHANGED
@@ -10,6 +10,72 @@ nav_order: 6
10
10
 
11
11
  ## main
12
12
 
13
+ ## 4.16.0
14
+
15
+ * 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.
16
+
17
+ *Ben Sheldon*
18
+
19
+ * Fix collection counter and iteration detection, and collection and reserved parameter validation, when `initialize` arguments are forwarded through anonymous arguments (`...` or `**`), such as by prepended modules and subclasses calling `super`.
20
+
21
+ *Ben Sheldon*
22
+
23
+ * Update development and documentation dependencies, baseline existing Audition findings, and accommodate updated lint rules.
24
+
25
+ *Joel Hawksley*
26
+
27
+ * Invalidate Action View's memoized template digests when a component registers with `ViewComponent::CacheDigest`, so a digest computed before the component loaded isn't served for the rest of the process.
28
+
29
+ *Erik Axel Nielsen*
30
+
31
+ * Raise errors encountered while computing a component's cache digest instead of degrading to an untracked component that can serve stale fragments.
32
+
33
+ *Erik Axel Nielsen*
34
+
35
+ * Give `<% cache %>` blocks inside a component's own template a digest, for components that `include ViewComponent::ExperimentallyCacheable`.
36
+
37
+ 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.
38
+
39
+ *Erik Axel Nielsen*
40
+
41
+ * Fix line numbers and, under coverage, missing output for ERB templates that Rails doesn't annotate.
42
+
43
+ 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.
44
+
45
+ Inline templates now decide the compensation when they compile rather than when the component class is defined, matching file templates.
46
+
47
+ *Ryutaro Mizokami*
48
+
49
+ * 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.
50
+
51
+ *Joel Hawksley*
52
+
53
+ ## 4.15.0
54
+
55
+ * Add experimental caching support, opt-in per component via `include ViewComponent::ExperimentallyCacheable`.
56
+
57
+ Components have never participated in Rails' template digests, so a `<% cache %>` block wrapping `render MyComponent.new` was never invalidated when the component changed ([#234](https://github.com/ViewComponent/view_component/issues/234), open since 2020).
58
+
59
+ Including the module registers the component with Rails' own `ActionView::Digestor`, so fragment caches are invalidated when the component's template, Ruby class, sidecar files, superclasses, child components, or rendered partials change. This includes components and partials rendered from inline templates and `#call` methods. Adding `cache_on` caches the component's own rendered output, optionally guarded by `if:`/`unless:`, and `.cache_digest` exposes the digest for use outside a request.
60
+
61
+ ```ruby
62
+ class MessageComponent < ViewComponent::Base
63
+ include ViewComponent::ExperimentallyCacheable
64
+
65
+ cache_on :message, unless: -> { message.draft? }
66
+
67
+ def initialize(message:)
68
+ @message = message
69
+ end
70
+ end
71
+ ```
72
+
73
+ **This API is experimental and may change or be removed in a non-major release.** It's shipping opt-in and per-component precisely so we can iterate on it in response to real-world use. **Please try it and tell us what breaks, what's missing, and what feels wrong in [#234](https://github.com/ViewComponent/view_component/issues/234).** We're especially interested in feedback on: whether `cache_on` is the right shape for declaring cache keys, how the feature behaves with slots and content blocks, and whether the `# Template Dependency:` escape hatch is sufficient for dynamic renders. See [the caching guide](https://viewcomponent.org/guide/caching.html) for details and known caveats.
74
+
75
+ This work builds directly on prior art from the community. The `cache_on` API and the case for component-local caching come from [#2126](https://github.com/ViewComponent/view_component/pull/2126) by *Reegan Viljoen*. The approach of integrating with Rails' digest tree rather than reimplementing it comes from [`view_component-cache_digest`](https://github.com/tildeio/view_component-cache_digest) by *Godfrey Chan*. The invalidation cases it's tested against were contributed by *JWShuff* and *timburgan*, drawing on [`view_component-fragment_caching`](https://github.com/patrickarnett/view_component-fragment_caching) by *Patrick Arnett*. The issue was opened and researched by *ozzyaaron*, *pinzonjulian*, and *Derek Kniffin*, and the digest workaround that surfaced the superclass gap came from *cannikin* and *rnestler*. Cache-key correctness issues (formats sharing an entry, positional `nil` collisions, conditional caching, and ignored `cache_on` blocks) were found and reported by *Reegan Viljoen*.
76
+
77
+ *Reegan Viljoen*, *Godfrey Chan*, *JWShuff*, *timburgan*, *Patrick Arnett*, *ozzyaaron*, *pinzonjulian*, *Derek Kniffin*, *cannikin*, *rnestler*, *Joel Hawksley*
78
+
13
79
  ## 4.14.0
14
80
 
15
81
  * Freeze `ReusedInstanceError::MESSAGE` and update `test_renders_component_with_asset_url` to build a fresh `AssetComponent` per render, fixing CI regressions introduced by the GHSA-8qw7-6phv-7q6p remediation.
@@ -631,7 +631,7 @@ module ViewComponent
631
631
  # @param spacer_component [ViewComponent::Base] Component instance to be rendered between items.
632
632
  # @param args [Arguments] Arguments to pass to the ViewComponent every time.
633
633
  def with_collection(collection, spacer_component: nil, **args)
634
- Collection.new(self, collection, spacer_component, **args)
634
+ Collection.new(self, collection, spacer_component, args)
635
635
  end
636
636
 
637
637
  # @private
@@ -716,7 +716,7 @@ module ViewComponent
716
716
  # @param parameter [Symbol] The parameter name used when rendering elements of a collection.
717
717
  def with_collection_parameter(parameter)
718
718
  @__vc_provided_collection_parameter = parameter
719
- @__vc_initialize_parameters = nil
719
+ @__vc_collection_arguments = nil
720
720
  end
721
721
 
722
722
  # Strips trailing whitespace from templates before compiling them.
@@ -746,10 +746,14 @@ module ViewComponent
746
746
  # rendering is optional.
747
747
  # @private
748
748
  def __vc_validate_collection_parameter!(validate_default: false)
749
- parameter = validate_default ? __vc_collection_parameter : __vc_provided_collection_parameter
749
+ if validate_default
750
+ return if __vc_collection_arguments[:parameter_accepted]
750
751
 
751
- return unless parameter
752
- return if __vc_initialize_parameter_names.include?(parameter) || __vc_splatted_keyword_argument_present?
752
+ raise MissingCollectionArgumentError.new(name, __vc_collection_parameter)
753
+ end
754
+
755
+ parameter = __vc_provided_collection_parameter
756
+ return if __vc_collection_parameter_accepted?(parameter)
753
757
 
754
758
  raise MissingCollectionArgumentError.new(name, parameter)
755
759
  end
@@ -766,51 +770,91 @@ module ViewComponent
766
770
 
767
771
  # @private
768
772
  def __vc_collection_parameter
769
- @__vc_provided_collection_parameter ||= name && name.demodulize.underscore.chomp("_component").to_sym
773
+ __vc_collection_arguments[:parameter]
770
774
  end
771
775
 
772
776
  # @private
773
777
  def __vc_collection_counter_parameter
774
- @__vc_collection_counter_parameter ||= :"#{__vc_collection_parameter}_counter"
778
+ __vc_collection_arguments[:counter_parameter]
775
779
  end
776
780
 
777
781
  # @private
778
782
  def __vc_counter_argument_present?
779
- __vc_initialize_parameter_names.include?(__vc_collection_counter_parameter)
783
+ __vc_collection_arguments[:counter]
780
784
  end
781
785
 
782
786
  # @private
783
787
  def __vc_collection_iteration_parameter
784
- @__vc_collection_iteration_parameter ||= :"#{__vc_collection_parameter}_iteration"
788
+ __vc_collection_arguments[:iteration_parameter]
785
789
  end
786
790
 
787
791
  # @private
788
792
  def __vc_iteration_argument_present?
789
- __vc_initialize_parameter_names.include?(__vc_collection_iteration_parameter)
793
+ __vc_collection_arguments[:iteration]
794
+ end
795
+
796
+ # @private
797
+ def __vc_build_collection_arguments
798
+ names =
799
+ if respond_to?(:attribute_names)
800
+ attribute_names.map(&:to_sym)
801
+ else
802
+ __vc_initialize_signatures.flat_map { |_owner, parameters| parameters.map(&:last) }
803
+ end
804
+ parameter = __vc_provided_collection_parameter || (name && name.demodulize.underscore.chomp("_component").to_sym)
805
+ counter_parameter = :"#{parameter}_counter"
806
+ iteration_parameter = :"#{parameter}_iteration"
807
+
808
+ @__vc_collection_arguments = Ractor.make_shareable(
809
+ {
810
+ initialize_parameter_names: names,
811
+ parameter: parameter,
812
+ parameter_accepted: parameter.nil? || names.include?(parameter) || __vc_splatted_keyword_argument_present?,
813
+ counter_parameter: counter_parameter,
814
+ counter: names.include?(counter_parameter),
815
+ iteration_parameter: iteration_parameter,
816
+ iteration: names.include?(iteration_parameter)
817
+ }
818
+ )
790
819
  end
791
820
 
792
821
  private
793
822
 
794
- def __vc_splatted_keyword_argument_present?
795
- __vc_initialize_parameters.flatten.include?(:keyrest)
823
+ def __vc_collection_arguments
824
+ @__vc_collection_arguments || __vc_build_collection_arguments
796
825
  end
797
826
 
798
- def __vc_initialize_parameter_names
799
- @__vc_initialize_parameter_names ||=
800
- if respond_to?(:attribute_names)
801
- attribute_names.map(&:to_sym)
802
- else
803
- __vc_initialize_parameters.map(&:last)
827
+ def __vc_collection_parameter_accepted?(parameter)
828
+ parameter.nil? || __vc_initialize_parameter_names.include?(parameter) || __vc_splatted_keyword_argument_present?
829
+ end
830
+
831
+ def __vc_splatted_keyword_argument_present?
832
+ prepended_modules = ancestors.grep(Class).flat_map { |klass| klass.ancestors.take_while { |mod| mod != klass } }
833
+
834
+ __vc_initialize_signatures.any? do |owner, parameters|
835
+ parameters.any? do |type, name|
836
+ type == :keyrest && (name != :** || !prepended_modules.include?(owner))
804
837
  end
838
+ end
805
839
  end
806
840
 
807
- def __vc_initialize_parameters
808
- @__vc_initialize_parameters ||= instance_method(:initialize).parameters
841
+ def __vc_initialize_parameter_names
842
+ __vc_collection_arguments[:initialize_parameter_names]
809
843
  end
810
844
 
811
- def __vc_provided_collection_parameter
812
- @__vc_provided_collection_parameter ||= nil
845
+ def __vc_initialize_signatures
846
+ signatures = []
847
+ method = instance_method(:initialize)
848
+ while method
849
+ signatures << [method.owner, method.parameters]
850
+ break unless method.parameters.any? { |type, _| type == :keyrest }
851
+
852
+ method = method.super_method
853
+ end
854
+ signatures
813
855
  end
856
+
857
+ attr_reader :__vc_provided_collection_parameter
814
858
  end
815
859
 
816
860
  ActiveSupport.run_load_hooks(:view_component, self)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view/dependency_tracker"
4
+
5
+ module ViewComponent
6
+ module CacheDigest
7
+ # Teaches `ActionView::DependencyTracker` to see components.
8
+ #
9
+ # Prepended to the tracker's singleton class rather than to a specific
10
+ # tracker implementation (`ERBTracker`, `RubyTracker`, or the trackers
11
+ # registered by the Haml and Slim gems). `find_dependencies` is the single
12
+ # seam every tracker flows through, so hooking it here works regardless of
13
+ # which handler a template uses and doesn't depend on tracker internals.
14
+ #
15
+ # @private
16
+ module DependencyTracking
17
+ def find_dependencies(name, template, view_paths = nil)
18
+ dependencies = super
19
+ source = template.source
20
+
21
+ # `# Template Dependency: SomeComponent` names a class, which Rails
22
+ # would resolve as a template path and never find. Swap it for the path
23
+ # the component is digested under, so the declaration resolves instead
24
+ # of becoming a missing node.
25
+ CacheDigest.explicit_component_dependencies(source).each do |declared, virtual_path|
26
+ dependencies = dependencies - [declared] + [virtual_path]
27
+ end
28
+
29
+ dependencies + CacheDigest.dependencies_in(template)
30
+ end
31
+
32
+ # @private
33
+ def self.install!
34
+ tracker = ActionView::DependencyTracker.singleton_class
35
+ return if tracker.include?(self)
36
+
37
+ tracker.prepend(self)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponent
4
+ module CacheDigest
5
+ # Synthesizes the templates Rails' `ActionView::Digestor` digests components from.
6
+ #
7
+ # Once `DependencyTracking` reports `view_component/cache_digest/foo_component`
8
+ # as a dependency, the Digestor tries to find a template at that path. No such
9
+ # file exists: a component's rendered output depends on its template *and* its
10
+ # Ruby class, its sidecar files, and its superclasses.
11
+ #
12
+ # This resolver answers with a synthetic template whose source encodes all of
13
+ # those inputs. The template is never compiled or rendered; the Digestor only
14
+ # reads `#source` to hash it and to scan it for further dependencies.
15
+ #
16
+ # @private
17
+ class Resolver < ActionView::Resolver
18
+ # Extensions whose contents are hashed into the synthetic source.
19
+ SIDECAR_EXTENSIONS = %w[yml yaml].freeze
20
+
21
+ class << self
22
+ def instance
23
+ INSTANCE
24
+ end
25
+ end
26
+
27
+ def find_templates(name, prefix, partial, details, locals = [])
28
+ virtual_path = [prefix.presence, name].compact.join("/")
29
+ component = CacheDigest.component_for(virtual_path)
30
+ return [] unless component
31
+
32
+ [build_template(component, virtual_path, details)]
33
+ end
34
+
35
+ def to_s
36
+ "ViewComponent::CacheDigest::Resolver"
37
+ end
38
+ alias_method :to_path, :to_s
39
+
40
+ def eql?(other)
41
+ self.class.equal?(other.class)
42
+ end
43
+ alias_method :==, :eql?
44
+
45
+ private
46
+
47
+ def build_template(component, virtual_path, details)
48
+ ActionView::Template.new(
49
+ source_for(component),
50
+ "view_component cache digest for #{component.name}",
51
+ ActionView::Template.handler_for_extension(:erb),
52
+ locals: [],
53
+ format: Array(details[:formats]).first || :html,
54
+ virtual_path: virtual_path
55
+ )
56
+ end
57
+
58
+ # The synthetic source. Every section exists to change this string when
59
+ # something the component renders from changes.
60
+ def source_for(component)
61
+ parts = []
62
+
63
+ # Safety net: this template should never be rendered, only digested.
64
+ parts << "<% raise ViewComponent::CacheDigestTemplateError.new(#{component.name.inspect}) %>"
65
+
66
+ # Content hashes of the Ruby files and sidecar files backing the
67
+ # component and its component superclasses. Hashing rather than
68
+ # inlining keeps the source small and avoids embedding Ruby that a
69
+ # tracker might misread as a render call.
70
+ source_files(component).each do |path|
71
+ parts << "<%# Resolved Dependency: #{path} #{file_digest(path)} %>"
72
+ end
73
+
74
+ # Everything the component renders from Ruby rather than from a
75
+ # template: `# Template Dependency:` declarations, components rendered
76
+ # from `#call` methods, and partials referenced by string path.
77
+ # Re-emitted so the Digestor resolves them as tree nodes.
78
+ ruby_dependencies(component).each do |dependency|
79
+ parts << "<%# Template Dependency: #{dependency} %>"
80
+ end
81
+
82
+ # Template sources verbatim, so trackers can discover the partials and
83
+ # components they render.
84
+ template_sources(component).each do |source|
85
+ parts << source
86
+ end
87
+
88
+ parts.join("\n")
89
+ end
90
+
91
+ # The component and any component superclasses, nearest first. Including
92
+ # ancestors means editing `ApplicationComponent` invalidates every
93
+ # component that inherits from it.
94
+ def component_ancestors(component)
95
+ component.ancestors.select do |ancestor|
96
+ ancestor.is_a?(Class) &&
97
+ ancestor <= ViewComponent::Base &&
98
+ ancestor != ViewComponent::Base
99
+ end
100
+ end
101
+
102
+ def source_files(component)
103
+ component_ancestors(component).flat_map { |ancestor|
104
+ [ancestor.identifier, *ancestor.sidecar_files(SIDECAR_EXTENSIONS)]
105
+ }.compact.uniq.select { |path| ::File.exist?(path) }
106
+ end
107
+
108
+ def template_files(component)
109
+ component_ancestors(component)
110
+ .flat_map { |ancestor| ancestor.sidecar_files(ActionView::Template::Handlers.extensions) }
111
+ .uniq
112
+ .select { |path| ::File.exist?(path) }
113
+ end
114
+
115
+ # Sidecar template files plus inline templates, which live in the Ruby
116
+ # file and so are invisible to Action View's trackers.
117
+ def template_sources(component)
118
+ sources = template_files(component).map { |path| ::File.read(path) }
119
+
120
+ component_ancestors(component).each do |ancestor|
121
+ inline_template = ancestor.__vc_inline_template
122
+ sources << inline_template.source if inline_template
123
+ end
124
+
125
+ sources.uniq
126
+ end
127
+
128
+ def explicit_dependencies(component)
129
+ ruby_sources(component).flat_map { |source|
130
+ declared = source.scan(CacheDigest::EXPLICIT_DEPENDENCY).flatten
131
+
132
+ # Component class names are translated to the path they're digested
133
+ # under; anything else is a template path already.
134
+ CacheDigest.explicit_component_dependencies(source).each do |name, virtual_path|
135
+ declared = declared - [name] + [virtual_path]
136
+ end
137
+
138
+ declared
139
+ }.uniq
140
+ end
141
+
142
+ # Everything a component renders from Ruby code rather than from a
143
+ # template. Action View's trackers only read templates, so a `#call`
144
+ # method that renders another component or a partial would otherwise go
145
+ # unnoticed.
146
+ def ruby_dependencies(component)
147
+ virtual_path = CacheDigest.virtual_path_for(component)
148
+
149
+ explicit_dependencies(component) |
150
+ ruby_sources(component).flat_map { |source|
151
+ CacheDigest.component_paths_in(source) |
152
+ CacheDigest.partial_paths_in(source, virtual_path)
153
+ }.uniq
154
+ end
155
+
156
+ def ruby_sources(component)
157
+ component_ancestors(component).filter_map { |ancestor|
158
+ path = ancestor.identifier
159
+ ::File.read(path) if path && ::File.exist?(path)
160
+ }
161
+ end
162
+
163
+ def file_digest(path)
164
+ ActiveSupport::Digest.hexdigest(::File.read(path))
165
+ end
166
+
167
+ # Built once at load time rather than memoized, so no class-level state
168
+ # is written after boot. The resolver is stateless: it reads from disk on
169
+ # every call so it can't go stale when a component changes.
170
+ INSTANCE = new
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/dependencies/autoload"
4
+ require "action_view/digestor"
5
+ require "action_view/render_parser"
6
+
7
+ module ViewComponent
8
+ # Integrates ViewComponents into Rails' template digest tree.
9
+ #
10
+ # Rails computes a digest for every template from its source and the templates
11
+ # it renders. That digest is mixed into the key of every `<% cache %>` block in
12
+ # the template, so editing a partial busts the caches of everything that
13
+ # renders it.
14
+ #
15
+ # Components are invisible to that mechanism for two reasons:
16
+ #
17
+ # 1. **Discovery** — `ActionView::DependencyTracker` doesn't recognize
18
+ # `render SomeComponent.new(...)` as a dependency.
19
+ # 2. **Resolution** — component templates live outside the view paths, and a
20
+ # component's rendered output depends on its Ruby class and sidecar files,
21
+ # not just its template.
22
+ #
23
+ # This module fixes both, reusing Rails' own `ActionView::Digestor` rather than
24
+ # reimplementing static analysis. Components opt in individually by including
25
+ # `ViewComponent::ExperimentallyCacheable`; until at least one component does,
26
+ # every hook here short-circuits.
27
+ #
28
+ # @private
29
+ module CacheDigest
30
+ extend ActiveSupport::Autoload
31
+
32
+ autoload :DependencyTracking
33
+ autoload :Resolver
34
+
35
+ # Prefix for the synthetic virtual paths components are digested under.
36
+ #
37
+ # Namespaced under `view_component/` so it can't collide with an
38
+ # application partial.
39
+ VIRTUAL_PATH_PREFIX = "view_component/cache_digest"
40
+
41
+ # Matches `render FooComponent`, `render(Foo::BarComponent.new(...))`,
42
+ # `render FooComponent.with_collection(...)`, etc.
43
+ #
44
+ # Deliberately a plain source scan rather than a tracker-specific hook: it
45
+ # behaves identically for the ERB tracker, the Prism-based Ruby tracker, and
46
+ # third-party Haml/Slim trackers.
47
+ RENDER_CALL = /
48
+ \brender(?:_to_string)?\b # render or render_to_string
49
+ \s*\(?\s* # optional opening paren
50
+ (?<const>
51
+ (?:::)?[A-Z]\w* # a constant
52
+ (?:::[A-Z]\w*)* # optionally namespaced
53
+ )
54
+ /x
55
+
56
+ # Rails' escape hatch for dependencies static analysis can't see.
57
+ EXPLICIT_DEPENDENCY = /#\s*Template Dependency:\s*(\S+)/
58
+ class << self
59
+ # Virtual paths of components that have opted into caching, mapped to
60
+ # their class names.
61
+ #
62
+ # Class *names* rather than class objects so the registry survives
63
+ # autoloader reloads without pinning stale constants in memory.
64
+ #
65
+ # @return [Hash{String => String}]
66
+ def registry
67
+ @registry ||= {}
68
+ end
69
+
70
+ # @return [Boolean] whether any component has opted in.
71
+ def enabled?
72
+ !registry.empty?
73
+ end
74
+
75
+ # @private
76
+ def register(component)
77
+ return unless component.virtual_path && component.name
78
+ return if registry[component.virtual_path] == component.name
79
+
80
+ registry[component.virtual_path] = component.name
81
+
82
+ # Components register as they load, and under lazy loading that happens
83
+ # after Action View has already digested and memoized some templates.
84
+ # Those digests were computed without this component's dependencies and
85
+ # would otherwise be served for the rest of the process, making the
86
+ # digest a function of load order rather than of source.
87
+ expire_digests
88
+ end
89
+
90
+ # The synthetic virtual path a component is digested under.
91
+ #
92
+ # @return [String, nil]
93
+ def virtual_path_for(component)
94
+ return unless component.respond_to?(:virtual_path) && component.virtual_path
95
+
96
+ "#{VIRTUAL_PATH_PREFIX}/#{component.virtual_path}"
97
+ end
98
+
99
+ # Resolve a synthetic virtual path back to the component that owns it.
100
+ #
101
+ # @return [Class, nil]
102
+ def component_for(virtual_path)
103
+ return unless virtual_path.start_with?("#{VIRTUAL_PATH_PREFIX}/")
104
+
105
+ name = registry[virtual_path.delete_prefix("#{VIRTUAL_PATH_PREFIX}/")]
106
+ return unless name
107
+
108
+ constantize_component(name)
109
+ end
110
+
111
+ # Scan a template's source for renders of cacheable components.
112
+ #
113
+ # Called for every template Rails digests, so it exits early when the
114
+ # feature is unused.
115
+ #
116
+ # @return [Array<String>] synthetic virtual paths
117
+ def dependencies_in(template)
118
+ return [] unless enabled?
119
+
120
+ component_paths_in(template.source)
121
+ end
122
+
123
+ # Scan arbitrary source (a template or a component's Ruby file) for
124
+ # renders of cacheable components.
125
+ #
126
+ # @return [Array<String>] synthetic virtual paths
127
+ def component_paths_in(source)
128
+ return [] unless source.is_a?(String) && source.include?("render")
129
+
130
+ source.scan(RENDER_CALL).flatten.uniq.filter_map do |constant_name|
131
+ component = constantize_component(constant_name)
132
+ virtual_path_for(component) if component
133
+ end
134
+ end
135
+
136
+ # Scan a component's Ruby source for partials referenced by string path,
137
+ # such as `render "posts/byline"` inside a `#call` method.
138
+ #
139
+ # Uses Rails' own render parser — the same one `RubyTracker` runs over
140
+ # compiled templates — rather than a second implementation of the same
141
+ # analysis. Its results are then narrowed to paths that appear verbatim in
142
+ # the source, which keeps string literals and discards the speculative
143
+ # `things/_thing` entries the parser infers from dynamic renders like
144
+ # `render @thing` or `render FooComponent.new`. Those would resolve to
145
+ # nothing and only add log noise; components rendered from Ruby are
146
+ # already found precisely by `component_paths_in`.
147
+ #
148
+ # @param source [String] Ruby source
149
+ # @param name [String] virtual path the source is being digested under
150
+ # @return [Array<String>] partial virtual paths
151
+ def partial_paths_in(source, name)
152
+ return [] unless source.is_a?(String) && source.include?("render")
153
+
154
+ RENDER_PARSER.new(name, source).render_calls.uniq.select do |path|
155
+ source.include?(path) || source.include?(path.sub(%r{(\A|/)_}, '\1'))
156
+ end
157
+ end
158
+
159
+ # Action View has shipped its render parser as a class (Rails 7.1, and
160
+ # again on main) and as a module holding a `Default` implementation
161
+ # chosen from Prism or Ripper (Rails 7.2 through 8.1).
162
+ #
163
+ # @param parser [Class, Module] `ActionView::RenderParser`
164
+ # @return [Class]
165
+ def resolve_render_parser(parser)
166
+ parser.is_a?(Class) ? parser : parser::Default
167
+ end
168
+
169
+ # Resolve `# Template Dependency: SomeComponent` declarations.
170
+ #
171
+ # Rails' escape hatch takes a template path, but the path a component is
172
+ # digested under is an internal detail. Naming the class instead keeps
173
+ # that detail out of application code, so `SomeComponent` is translated
174
+ # to the path the Digestor can resolve.
175
+ #
176
+ # @return [Array<Array(String, String)>] pairs of declared name and
177
+ # synthetic virtual path
178
+ def explicit_component_dependencies(source)
179
+ return [] unless source.is_a?(String) && source.include?("Template Dependency:")
180
+
181
+ source.scan(EXPLICIT_DEPENDENCY).flatten.uniq.filter_map do |declared|
182
+ next unless /\A(?:::)?[A-Z]/.match?(declared)
183
+
184
+ component = constantize_component(declared)
185
+ [declared, virtual_path_for(component)] if component
186
+ end
187
+ end
188
+
189
+ # Compute the digest of a component using Rails' digest tree.
190
+ #
191
+ # @param component [Class] a component that includes `ExperimentallyCacheable`
192
+ # @param finder [ActionView::LookupContext]
193
+ # @param format [Symbol]
194
+ # @return [String]
195
+ def digest(component, finder: default_finder, format: :html)
196
+ virtual_path = virtual_path_for(component)
197
+ return "" unless virtual_path
198
+
199
+ ActionView::Digestor.digest(name: virtual_path, format: format, finder: finder)
200
+ end
201
+
202
+ # A lookup context for digesting components outside a request, where no
203
+ # view context (and therefore no finder) exists.
204
+ #
205
+ # @return [ActionView::LookupContext]
206
+ def default_finder
207
+ # Not memoized across reloads: view paths change when the app reloads.
208
+ ActionView::LookupContext.new(ActionController::Base.view_paths)
209
+ end
210
+
211
+ # Wire the tracker and resolver into Action View.
212
+ #
213
+ # Called each time a component includes `ExperimentallyCacheable`. Both
214
+ # steps below are individually idempotent, so no "already installed" flag
215
+ # is kept. Both hooks short-circuit while the registry is empty, so
216
+ # applications that never opt in are unaffected.
217
+ #
218
+ # @private
219
+ def install!
220
+ DependencyTracking.install!
221
+
222
+ ActiveSupport.on_load(:action_controller_base) do
223
+ resolver = ViewComponent::CacheDigest::Resolver.instance
224
+
225
+ append_view_path(resolver) unless view_paths.include?(resolver)
226
+ end
227
+ end
228
+
229
+ private
230
+
231
+ # Drop Action View's memoized template digests, leaving its resolver
232
+ # caches alone: no template changed, only the set of dependencies the
233
+ # Digestor can see.
234
+ def expire_digests
235
+ ActionView::LookupContext::DetailsKey.digest_caches.each(&:clear)
236
+ end
237
+
238
+ # Resolve a constant name to a component that opted into caching.
239
+ #
240
+ # Returns nil for anything else, including constants that don't exist.
241
+ # Autoloading here is safe: the template is about to render this constant
242
+ # anyway.
243
+ def constantize_component(constant_name)
244
+ component = constant_name.safe_constantize
245
+ return unless component.is_a?(Class)
246
+ return unless component.respond_to?(:__vc_cacheable?) && component.__vc_cacheable?
247
+
248
+ component
249
+ end
250
+ end
251
+
252
+ # Resolved once at load time rather than memoized, so no class-level state
253
+ # is written after boot.
254
+ RENDER_PARSER = resolve_render_parser(ActionView::RenderParser)
255
+ end
256
+ end
@@ -12,6 +12,9 @@ module ViewComponent
12
12
 
13
13
  delegate :size, to: :@collection
14
14
 
15
+ EMPTY_SPACER = "".html_safe.freeze
16
+ private_constant :EMPTY_SPACER
17
+
15
18
  def render_in(view_context, **_, &block)
16
19
  rendered = components.map! do |component|
17
20
  component.render_in(view_context, &block)
@@ -36,18 +39,27 @@ module ViewComponent
36
39
  # Always rebuild child component instances per render to avoid leaking
37
40
  # request-scoped state from a previous render into a later one (GHSA).
38
41
  def components
39
- iterator = ActionView::PartialIteration.new(@collection.size)
40
-
41
42
  component.__vc_validate_collection_parameter!(validate_default: true)
42
43
 
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
+
43
52
  @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
47
59
  end
48
60
  end
49
61
 
50
- def initialize(component, object, spacer_component, **options)
62
+ def initialize(component, object, spacer_component, options = {})
51
63
  @component = component
52
64
  @collection = collection_variable(object || [])
53
65
  @spacer_component = spacer_component
@@ -62,20 +74,11 @@ module ViewComponent
62
74
  end
63
75
  end
64
76
 
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
-
74
77
  # Render the spacer through a fresh `dup` so a collection rendered multiple
75
78
  # times does not reuse (and trip the single-render guard on) the spacer
76
79
  # instance passed by the caller.
77
80
  def rendered_spacer(view_context)
78
- return "" unless @spacer_component
81
+ return EMPTY_SPACER unless @spacer_component
79
82
 
80
83
  spacer = @spacer_component.dup
81
84
  if spacer.instance_variable_defined?(:@__vc_rendered)
@@ -41,6 +41,8 @@ module ViewComponent
41
41
  return false
42
42
  end
43
43
 
44
+ @component.__vc_build_collection_arguments
45
+
44
46
  if raise_errors
45
47
  @component.__vc_validate_initialization_parameters!
46
48
  @component.__vc_validate_collection_parameter!
@@ -28,6 +28,7 @@ module ViewComponent
28
28
  initializer "view_component.enable_instrumentation" do |app|
29
29
  ActiveSupport.on_load(:view_component) do
30
30
  if app.config.view_component.instrumentation_enabled.present?
31
+ ViewComponent::Instrumentation.enabled = true
31
32
  ViewComponent::Base.prepend(ViewComponent::Instrumentation)
32
33
  end
33
34
  end
@@ -232,4 +232,39 @@ module ViewComponent
232
232
  super(MESSAGE.gsub("SETTER_METHOD_NAME", setter_method_name.to_s).gsub("SETTER_NAME", setter_name.to_s))
233
233
  end
234
234
  end
235
+
236
+ class CacheDigestTemplateError < StandardError
237
+ MESSAGE =
238
+ "The synthetic cache digest template for COMPONENT was rendered.\n\n" \
239
+ "This template exists only so Rails can compute a cache digest for the " \
240
+ "component and is never meant to be rendered. Render the component " \
241
+ "itself instead.".freeze
242
+
243
+ def initialize(component_name)
244
+ super(MESSAGE.gsub("COMPONENT", component_name.to_s))
245
+ end
246
+ end
247
+
248
+ class UndefinedCacheKeyMethodError < StandardError
249
+ MESSAGE =
250
+ "`cache_on` declared `METHOD` on COMPONENT, but no such method is defined.\n\n" \
251
+ "To fix this issue, define `METHOD` or remove it from `cache_on`.".freeze
252
+
253
+ def initialize(component_name, method_name)
254
+ super(MESSAGE.gsub("COMPONENT", component_name.to_s).gsub("METHOD", method_name.to_s))
255
+ end
256
+ end
257
+
258
+ class ContentPassedToCachedComponentError < StandardError
259
+ MESSAGE =
260
+ "COMPONENT declares `cache_on`, so it caches its own output, but its caller passed it content.\n\n" \
261
+ "Content and slots set by the caller aren't part of the cache key, so caching them would risk " \
262
+ "serving one caller's content to another.\n\n" \
263
+ "To fix this issue, either remove `cache_on` from COMPONENT, or move the content into the " \
264
+ "component and derive it from the values declared in `cache_on`.".freeze
265
+
266
+ def initialize(component_name)
267
+ super(MESSAGE.gsub("COMPONENT", component_name.to_s))
268
+ end
269
+ end
235
270
  end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "view_component/cache_digest"
4
+
5
+ module ViewComponent
6
+ # Experimental caching support for ViewComponents.
7
+ #
8
+ # **This API is experimental.** It may change or be removed in a non-major
9
+ # release. Please share feedback in
10
+ # https://github.com/ViewComponent/view_component/issues/234.
11
+ #
12
+ # Including this module does two things:
13
+ #
14
+ # 1. Registers the component with Rails' template digest tree, so a
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.
19
+ # 2. Enables the `cache_on` macro, which caches the component's own rendered
20
+ # output.
21
+ #
22
+ # ```ruby
23
+ # class MessageComponent < ViewComponent::Base
24
+ # include ViewComponent::ExperimentallyCacheable
25
+ #
26
+ # cache_on :message
27
+ #
28
+ # def initialize(message:)
29
+ # @message = message
30
+ # end
31
+ # end
32
+ # ```
33
+ module ExperimentallyCacheable
34
+ extend ActiveSupport::Concern
35
+
36
+ # Stands in for `nil` in the cache key. Without it, `expand_cache_key`
37
+ # renders `nil` and `""` identically, so two components differing only in
38
+ # that respect would share an entry.
39
+ NIL_CACHE_VALUE = :__vc_nil
40
+
41
+ included do
42
+ ViewComponent::CacheDigest.install!
43
+ ViewComponent::CacheDigest.register(self)
44
+ end
45
+
46
+ class_methods do
47
+ # Declare the values that identify a rendering of this component.
48
+ #
49
+ # Each argument names a method on the component whose value is mixed into
50
+ # the cache key, alongside a digest of the component's source. Private
51
+ # methods are allowed.
52
+ #
53
+ # ```ruby
54
+ # cache_on :message, :current_user
55
+ # ```
56
+ #
57
+ # Calling `cache_on` opts the component into caching its own output.
58
+ # Without it, including this module only registers the component with
59
+ # Rails' digest tree.
60
+ #
61
+ # Pass `if:` or `unless:` to cache only some renders. Both accept a method
62
+ # name or a proc evaluated on the component:
63
+ #
64
+ # ```ruby
65
+ # cache_on :message, if: :persisted?
66
+ # cache_on :message, unless: -> { message.draft? }
67
+ # ```
68
+ #
69
+ # These methods are called before the component renders, so they can only
70
+ # depend on the component's own state, not on `helpers` or the view
71
+ # context.
72
+ #
73
+ # @param methods [Array<Symbol>] Methods whose values form the cache key.
74
+ # @param options [Hash] `:if` and/or `:unless` conditions.
75
+ # @return [void]
76
+ def cache_on(*methods, **options, &block)
77
+ if block
78
+ raise ArgumentError,
79
+ "`cache_on` doesn't accept a block. Name the methods whose values form the cache key, " \
80
+ "such as `cache_on :message`."
81
+ end
82
+
83
+ methods.each do |method|
84
+ next if method.is_a?(Symbol) || method.is_a?(String)
85
+
86
+ raise ArgumentError,
87
+ "`cache_on` expects method names as symbols, got #{method.class}. " \
88
+ "Define a method for the value and name it, such as `cache_on :message`."
89
+ end
90
+
91
+ unknown = options.keys - %i[if unless]
92
+ if unknown.any?
93
+ raise ArgumentError,
94
+ "`cache_on` received unknown #{"option".pluralize(unknown.count)} " \
95
+ "#{unknown.map(&:inspect).to_sentence}. Supported options are `:if` and `:unless`."
96
+ end
97
+
98
+ @__vc_cache_on = __vc_cache_on | methods.map(&:to_sym)
99
+ @__vc_cache_if = options[:if] if options.key?(:if)
100
+ @__vc_cache_unless = options[:unless] if options.key?(:unless)
101
+ end
102
+
103
+ # @private
104
+ def __vc_cache_on
105
+ @__vc_cache_on ||= superclass.respond_to?(:__vc_cache_on) ? superclass.__vc_cache_on : []
106
+ end
107
+
108
+ # @private
109
+ def __vc_cache_if
110
+ return @__vc_cache_if if defined?(@__vc_cache_if)
111
+
112
+ superclass.__vc_cache_if if superclass.respond_to?(:__vc_cache_if)
113
+ end
114
+
115
+ # @private
116
+ def __vc_cache_unless
117
+ return @__vc_cache_unless if defined?(@__vc_cache_unless)
118
+
119
+ superclass.__vc_cache_unless if superclass.respond_to?(:__vc_cache_unless)
120
+ end
121
+
122
+ # @private
123
+ def __vc_cacheable?
124
+ true
125
+ end
126
+
127
+ # Whether this component caches its own rendered output.
128
+ #
129
+ # @return [Boolean]
130
+ def __vc_caches_output?
131
+ __vc_cache_on.any?
132
+ end
133
+
134
+ # A digest of everything this component renders from: its template, its
135
+ # Ruby class, its sidecar files, its superclasses, and the components and
136
+ # partials it renders.
137
+ #
138
+ # Computed with Rails' own `ActionView::Digestor`, so it's the same digest
139
+ # used to invalidate `<% cache %>` blocks.
140
+ #
141
+ # Usable outside a request, where no view context exists:
142
+ #
143
+ # ```ruby
144
+ # MessageComponent.cache_digest
145
+ # ```
146
+ #
147
+ # @param finder [ActionView::LookupContext] Defaults to a lookup context
148
+ # built from `ActionController::Base.view_paths`.
149
+ # @param format [Symbol]
150
+ # @return [String]
151
+ def cache_digest(finder: nil, format: :html)
152
+ ViewComponent::CacheDigest.digest(
153
+ self,
154
+ finder: finder || ViewComponent::CacheDigest.default_finder,
155
+ format: format
156
+ )
157
+ end
158
+
159
+ # @private
160
+ def inherited(child)
161
+ super
162
+ ViewComponent::CacheDigest.register(child)
163
+ end
164
+ end
165
+
166
+ # Renders the component, reading from and writing to the Rails cache when
167
+ # `cache_on` has been declared.
168
+ #
169
+ # @private
170
+ def render_in(view_context, **, &block)
171
+ return super unless self.class.__vc_caches_output?
172
+
173
+ # Content provided by the caller isn't part of the cache key, so caching
174
+ # it would serve one caller's content to another. Raised whether or not
175
+ # caching is currently enabled, so the conflict surfaces in development
176
+ # and test rather than only in production.
177
+ if block || __vc_content_set_by_with_content_defined? || __vc_slots_set_by_caller?
178
+ raise ContentPassedToCachedComponentError.new(self.class.name)
179
+ end
180
+
181
+ return super unless __vc_cache_enabled?(view_context)
182
+
183
+ store = Rails.cache
184
+ key = cache_key(view_context)
185
+
186
+ if (cached = store.read(key))
187
+ # Safe to mark as HTML-safe: the cached string was produced by this same
188
+ # rendering pipeline, which escapes output before it's written.
189
+ return cached.html_safe
190
+ end
191
+
192
+ super.tap do |output|
193
+ store.write(key, output.to_s)
194
+ end
195
+ end
196
+
197
+ # The cache key for this rendering of the component.
198
+ #
199
+ # Combines the component's identity, its source digest, the requested
200
+ # format and variant, the current locale, and the values declared with
201
+ # `cache_on`. Override for full control.
202
+ #
203
+ # @param view_context [ActionView::Base]
204
+ # @return [String]
205
+ def cache_key(view_context = nil)
206
+ lookup_context = view_context&.lookup_context
207
+ format = __vc_cache_format(lookup_context)
208
+
209
+ parts = [
210
+ "view_component",
211
+ self.class.virtual_path,
212
+ self.class.cache_digest(finder: lookup_context, format: format),
213
+ # Included in its own right, not just as a digest input: components that
214
+ # render every format from one template have the same digest for each.
215
+ format,
216
+ __vc_cache_variant(lookup_context),
217
+ I18n.locale,
218
+ *__vc_cache_on_values
219
+ ]
220
+
221
+ # Positions are significant, so nils are substituted rather than removed.
222
+ # Compacting the array would let a nil in one position collapse into a nil
223
+ # in another.
224
+ ActiveSupport::Cache.expand_cache_key(
225
+ parts.map { |part| part.nil? ? NIL_CACHE_VALUE : part }
226
+ )
227
+ end
228
+
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
+
256
+ private
257
+
258
+ # Slots set by the caller via `with_*`. Checked before rendering, so slots
259
+ # a component fills in for itself with a `default_*` method — which resolve
260
+ # lazily during the render — aren't counted.
261
+ def __vc_slots_set_by_caller?
262
+ defined?(@__vc_set_slots) && @__vc_set_slots.present?
263
+ end
264
+
265
+ def __vc_cache_enabled?(view_context)
266
+ return false unless defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
267
+ return false unless __vc_cache_conditions_met?
268
+
269
+ controller = view_context.try(:controller)
270
+ controller.respond_to?(:perform_caching) && controller.perform_caching
271
+ end
272
+
273
+ def __vc_cache_conditions_met?
274
+ if (condition = self.class.__vc_cache_if)
275
+ return false unless __vc_evaluate_cache_condition(condition)
276
+ end
277
+
278
+ if (condition = self.class.__vc_cache_unless)
279
+ return false if __vc_evaluate_cache_condition(condition)
280
+ end
281
+
282
+ true
283
+ end
284
+
285
+ def __vc_evaluate_cache_condition(condition)
286
+ return instance_exec(&condition) if condition.respond_to?(:to_proc) && !condition.is_a?(Symbol)
287
+
288
+ unless respond_to?(condition, true)
289
+ raise UndefinedCacheKeyMethodError.new(self.class.name, condition)
290
+ end
291
+
292
+ send(condition)
293
+ end
294
+
295
+ def __vc_cache_on_values
296
+ self.class.__vc_cache_on.map do |method_name|
297
+ unless respond_to?(method_name, true)
298
+ raise UndefinedCacheKeyMethodError.new(self.class.name, method_name)
299
+ end
300
+
301
+ send(method_name)
302
+ end
303
+ end
304
+
305
+ def __vc_cache_format(lookup_context)
306
+ Array(lookup_context&.formats).first || :html
307
+ end
308
+
309
+ def __vc_cache_variant(lookup_context)
310
+ Array(lookup_context&.variants).first
311
+ end
312
+ end
313
+ end
@@ -8,8 +8,12 @@ module ViewComponent # :nodoc:
8
8
  mod.prepend(self) unless self <= ViewComponent::Instrumentation
9
9
  end
10
10
 
11
+ class << self
12
+ attr_accessor :enabled
13
+ end
14
+
11
15
  def render_in(...)
12
- return super if !Rails.application.config.view_component.instrumentation_enabled.present?
16
+ return super unless Instrumentation.enabled
13
17
 
14
18
  payload = {
15
19
  name: self.class.name,
@@ -24,8 +24,21 @@ module ViewComponent
24
24
  # The hash `user_details` would normally be the standard arguments that
25
25
  # `render` accepts, but there's currently no mechanism for users to
26
26
  # provide these when calling render on a ViewComponent.
27
- details, cached = detail_args_for(user_details)
28
- cached || ActionView::TemplateDetails::Requested.new(**details)
27
+ if user_details.equal?(EMPTY_DETAILS)
28
+ # Fast path: memoize the empty-details Requested per LookupContext.
29
+ # Rendered many times with the same context, the tuple/Requested
30
+ # allocations from ActionView are then paid at most once.
31
+ cached = instance_variable_defined?(:@__vc_requested_details_cache) &&
32
+ @__vc_requested_details_cache
33
+ return cached if cached
34
+
35
+ details, from_cache = detail_args_for(EMPTY_DETAILS)
36
+ @__vc_requested_details_cache =
37
+ from_cache || ActionView::TemplateDetails::Requested.new(**details)
38
+ else
39
+ details, from_cache = detail_args_for(user_details)
40
+ from_cache || ActionView::TemplateDetails::Requested.new(**details)
41
+ end
29
42
  end
30
43
  end
31
44
  end
@@ -60,13 +60,7 @@ module ViewComponent
60
60
  # Strip the annotation line to maintain correct line numbers when coverage
61
61
  # is running (avoids segfault from negative lineno)
62
62
  def strip_annotation_line?
63
- erb_newline_compensation_needed? &&
64
- coverage_running? &&
65
- ActionView::Base.annotate_rendered_view_with_filenames
66
- end
67
-
68
- def erb_newline_compensation_needed?
69
- Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb
63
+ erb_newline_compensation_needed? && coverage_running?
70
64
  end
71
65
 
72
66
  def compiled_source
@@ -82,22 +76,11 @@ module ViewComponent
82
76
  def initialize(component:, inline_template:)
83
77
  details = ActionView::TemplateDetails.new(nil, inline_template.language.to_sym, nil, nil)
84
78
 
85
- # Rails 8.1 added a newline to compiled ERB output (rails/rails#53731).
86
- # Subtract 1 to compensate for correct line numbers in stack traces.
87
- # Inline templates start at line 2+ (defined inside a class), so this
88
- # won't result in negative line numbers that cause segfaults with coverage.
89
- lineno =
90
- if Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 && details.handler == :erb
91
- inline_template.lineno - 1
92
- else
93
- inline_template.lineno
94
- end
95
-
96
79
  super(
97
80
  component: component,
98
81
  details: details,
99
82
  path: inline_template.path,
100
- lineno: lineno,
83
+ lineno: inline_template.lineno,
101
84
  )
102
85
 
103
86
  @source = inline_template.source.dup
@@ -106,6 +89,19 @@ module ViewComponent
106
89
  def type
107
90
  :inline
108
91
  end
92
+
93
+ private
94
+
95
+ # Rails 8.1 added a newline to compiled ERB output (rails/rails#53731).
96
+ # Subtract 1 to compensate for correct line numbers in stack traces.
97
+ # Inline templates start at line 2+ (defined inside a class), so this
98
+ # won't result in negative line numbers that cause segfaults with coverage.
99
+ #
100
+ # Computed at compile time rather than at initialization because the
101
+ # annotation setting can change between the two.
102
+ def lineno
103
+ erb_newline_compensation_needed? ? super - 1 : super
104
+ end
109
105
  end
110
106
 
111
107
  class InlineCall < Template
@@ -196,6 +192,20 @@ module ViewComponent
196
192
 
197
193
  attr_reader :lineno
198
194
 
195
+ def erb_newline_compensation_needed?
196
+ Rails::VERSION::MAJOR >= 8 && Rails::VERSION::MINOR > 0 &&
197
+ details.handler == :erb &&
198
+ annotated?
199
+ end
200
+
201
+ # The newline compensated for lives inside the `<!-- BEGIN ... -->`
202
+ # annotation, which Rails only prepends to HTML templates, and only when
203
+ # annotations are enabled. Compensating outside those conditions shifts
204
+ # backtraces by a line and, under coverage, strips real template source.
205
+ def annotated?
206
+ ActionView::Base.annotate_rendered_view_with_filenames && html?
207
+ end
208
+
199
209
  def compiled_source
200
210
  handler = details.handler_class
201
211
  this_source = source
@@ -3,7 +3,7 @@
3
3
  module ViewComponent
4
4
  module VERSION
5
5
  MAJOR = 4
6
- MINOR = 14
6
+ MINOR = 16
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
@@ -8,10 +8,12 @@ module ViewComponent
8
8
  extend ActiveSupport::Autoload
9
9
 
10
10
  autoload :Base
11
+ autoload :CacheDigest
11
12
  autoload :Compiler
12
13
  autoload :CompileCache
13
14
  autoload :Config
14
15
  autoload :Deprecation
16
+ autoload :ExperimentallyCacheable
15
17
  autoload :InlineTemplate
16
18
  autoload :Instrumentation
17
19
  autoload :Preview
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: view_component
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.14.0
4
+ version: 4.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ViewComponent Team
@@ -91,6 +91,9 @@ files:
91
91
  - lib/view_component.rb
92
92
  - lib/view_component/active_job_serializer.rb
93
93
  - lib/view_component/base.rb
94
+ - lib/view_component/cache_digest.rb
95
+ - lib/view_component/cache_digest/dependency_tracking.rb
96
+ - lib/view_component/cache_digest/resolver.rb
94
97
  - lib/view_component/collection.rb
95
98
  - lib/view_component/compile_cache.rb
96
99
  - lib/view_component/compiler.rb
@@ -99,6 +102,7 @@ files:
99
102
  - lib/view_component/deprecation.rb
100
103
  - lib/view_component/engine.rb
101
104
  - lib/view_component/errors.rb
105
+ - lib/view_component/experimentally_cacheable.rb
102
106
  - lib/view_component/inline_template.rb
103
107
  - lib/view_component/instrumentation.rb
104
108
  - lib/view_component/preview.rb
@@ -138,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
142
  - !ruby/object:Gem::Version
139
143
  version: '0'
140
144
  requirements: []
141
- rubygems_version: 4.0.16
145
+ rubygems_version: 4.0.20
142
146
  specification_version: 4
143
147
  summary: A framework for building reusable, testable & encapsulated view components
144
148
  in Ruby on Rails.