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 +4 -4
- data/app/controllers/view_components_system_test_controller.rb +1 -0
- data/docs/CHANGELOG.md +66 -0
- data/lib/view_component/base.rb +66 -22
- data/lib/view_component/cache_digest/dependency_tracking.rb +41 -0
- data/lib/view_component/cache_digest/resolver.rb +173 -0
- data/lib/view_component/cache_digest.rb +256 -0
- data/lib/view_component/collection.rb +19 -16
- data/lib/view_component/compiler.rb +2 -0
- data/lib/view_component/engine.rb +1 -0
- data/lib/view_component/errors.rb +35 -0
- data/lib/view_component/experimentally_cacheable.rb +313 -0
- data/lib/view_component/instrumentation.rb +5 -1
- data/lib/view_component/request_details.rb +15 -2
- data/lib/view_component/template.rb +29 -19
- data/lib/view_component/version.rb +1 -1
- data/lib/view_component.rb +2 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b893658a7831510ca06effa1b9abb1d50669a6910c31049f4225a164987e858
|
|
4
|
+
data.tar.gz: c4e55c9e7f4e49117c8abbaeaafb077c535be72d3b78775283e8599110cd47aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/view_component/base.rb
CHANGED
|
@@ -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,
|
|
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
|
-
@
|
|
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
|
-
|
|
749
|
+
if validate_default
|
|
750
|
+
return if __vc_collection_arguments[:parameter_accepted]
|
|
750
751
|
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
773
|
+
__vc_collection_arguments[:parameter]
|
|
770
774
|
end
|
|
771
775
|
|
|
772
776
|
# @private
|
|
773
777
|
def __vc_collection_counter_parameter
|
|
774
|
-
|
|
778
|
+
__vc_collection_arguments[:counter_parameter]
|
|
775
779
|
end
|
|
776
780
|
|
|
777
781
|
# @private
|
|
778
782
|
def __vc_counter_argument_present?
|
|
779
|
-
|
|
783
|
+
__vc_collection_arguments[:counter]
|
|
780
784
|
end
|
|
781
785
|
|
|
782
786
|
# @private
|
|
783
787
|
def __vc_collection_iteration_parameter
|
|
784
|
-
|
|
788
|
+
__vc_collection_arguments[:iteration_parameter]
|
|
785
789
|
end
|
|
786
790
|
|
|
787
791
|
# @private
|
|
788
792
|
def __vc_iteration_argument_present?
|
|
789
|
-
|
|
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
|
|
795
|
-
|
|
823
|
+
def __vc_collection_arguments
|
|
824
|
+
@__vc_collection_arguments || __vc_build_collection_arguments
|
|
796
825
|
end
|
|
797
826
|
|
|
798
|
-
def
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
|
808
|
-
|
|
841
|
+
def __vc_initialize_parameter_names
|
|
842
|
+
__vc_collection_arguments[:initialize_parameter_names]
|
|
809
843
|
end
|
|
810
844
|
|
|
811
|
-
def
|
|
812
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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
|
|
81
|
+
return EMPTY_SPACER unless @spacer_component
|
|
79
82
|
|
|
80
83
|
spacer = @spacer_component.dup
|
|
81
84
|
if spacer.instance_variable_defined?(:@__vc_rendered)
|
|
@@ -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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
data/lib/view_component.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|