Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ jobs:
echo "Error: patch version must be numeric"
exit 1
fi
if [ -n "$PRE" ] && ! [[ "$PRE" =~ ^[a-zA-Z0-9.-]+$ ]]; then
echo "Error: pre-release version contains invalid characters"
if [ -n "$PRE" ] && ! [[ "$PRE" =~ ^[a-zA-Z0-9.-]+$ && "$PRE" =~ [a-zA-Z] ]]; then
echo "Error: pre-release version must contain a letter and only use letters, numbers, dots, and hyphens"
exit 1
fi

Expand All @@ -73,7 +73,7 @@ jobs:
major=$(grep "MAJOR = " lib/view_component/version.rb | grep -o "[0-9]\+")
minor=$(grep "MINOR = " lib/view_component/version.rb | grep -o "[0-9]\+")
patch=$(grep "PATCH = " lib/view_component/version.rb | grep -o "[0-9]\+")
pre=$(grep "PRE = " lib/view_component/version.rb | grep -o "\"[^\"]*\"" | tr -d '"')
pre=$(sed -n 's/.*PRE = "\(.*\)"/\1/p' lib/view_component/version.rb)
echo "major=$major" >> $GITHUB_OUTPUT
echo "minor=$minor" >> $GITHUB_OUTPUT
echo "patch=$patch" >> $GITHUB_OUTPUT
Expand Down
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ nav_order: 6

## main

* Prevent self-cache key collisions and track component references in views and format-specific partial dependencies.

*Joel Hawksley*

* Isolate preview and request parameters, honor preview locale callbacks, restore controller view contexts, and accept nil translation keys.

*Joel Hawksley*

* Honor negative generator flags and preserve pre-release versions throughout release automation. Report gem publishing failures.

*Joel Hawksley*

* Fix fragment cache collisions after `render_parent`, missing relative-partial and inherited-template dependencies, format fallback cache collisions, and stale cached output after development template edits.

*Joel Hawksley*
Expand Down
4 changes: 3 additions & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Releases are automated using GitHub Actions. Here's the process:
- **major**: Major version number
- **minor**: Minor version number
- **patch**: Patch version number
- **pre**: Optional pre-release label (leave blank for stable release)
- **pre**: Optional pre-release label, such as `rc1` or `beta.1` (leave blank for a stable release)
3. Click **Run workflow**
4. The workflow will:
- Create a release branch
Expand All @@ -180,6 +180,8 @@ Releases are automated using GitHub Actions. Here's the process:
- Pushes the tag to trigger gem publishing
7. The gem is published to RubyGems automatically via GitHub Actions (requires approval from a maintainer in the "release" environment)

Pre-release labels must contain a letter. They're included in the version, release branch, and tag, and the GitHub release is marked as a pre-release. Failures to push the tag or start gem publishing fail the workflow.

## Governance

ViewComponent is built by over a hundred members of the community. Project membership has several levels:
Expand Down
4 changes: 3 additions & 1 deletion docs/guide/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class PostComponent < ViewComponent::Base
end
```

The cache key also includes the component's digest, ordered format and variant fallbacks, and locale. As with Rails' `cache` helper, caching only happens when `perform_caching` is enabled.
The cache key also includes the component's digest, ordered format and variant fallbacks, partial lookup prefixes, and locale. As with Rails' `cache` helper, caching only happens when `perform_caching` is enabled.

To cache only some renders, pass `if:` or `unless:` with a method name or a proc:

Expand All @@ -61,6 +61,8 @@ end

Dependencies are found by scanning the component's templates, its Ruby class, and the modules it includes. Every component class named there counts, whether it's passed to `render`, built by a method, or assigned to a variable, and so does every partial rendered by its path. Slots count the class they're declared with, including one named by a string.

Component classes assigned to variables in Rails view templates are tracked too.

Relative partial names, such as `render "heading"`, track matching partials in every directory of the view paths, since the calling controller determines which one renders. Use a full path to limit invalidation to one partial.

A class or partial name built at runtime can't be found that way. Declare it with Rails' `# Template Dependency:` comment in the Ruby file or template, naming partials by path and components by class:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/generators.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ bin/rails generate view_component:component Sections::Example title content

You can specify options when running the generator. To alter the default values project-wide, define the configuration settings described in [API docs](/api.html#configuration).

Use `--no-sidecar`, `--no-stimulus`, or `--no-typescript` to override an enabled default for one invocation.

Generated ViewComponents are added to `app/components` by default. Set `config.view_component.generate.path` to use a different path.

```ruby
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class ExampleComponentTest < ViewComponent::TestCase
end
```

Each call uses isolated request parameters and runs the preview controller's callbacks. Pass `params: {locale: "fr"}` to render in a different locale.

## Testing components with behaviors

To test ViewComponents with behaviors, visit a preview in a system test:
Expand Down
18 changes: 15 additions & 3 deletions lib/generators/view_component/abstract_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ def self.included(generator)
generator.class_option :skip_suffix, type: :boolean, default: false
end

def initialize(...)
super
generate = ViewComponent::Base.config.generate
defaults = {
"sidecar" => generate.sidecar,
"stimulus" => generate.stimulus_controller,
"typescript" => generate.typescript
}.slice(*self.class.class_options.keys.map(&:to_s))
# Resolve defaults once so hooks inherit explicit false values as well.
self.options = options.class.new(defaults.merge(options)).freeze
end

def copy_view_file
template("component.html.#{engine_name}", destination) unless options["inline"] || options["call"]
end
Expand Down Expand Up @@ -50,15 +62,15 @@ def stimulus_controller
end

def sidecar?
options["sidecar"] || ViewComponent::Base.config.generate.sidecar
options["sidecar"]
end

def stimulus?
options["stimulus"] || ViewComponent::Base.config.generate.stimulus_controller
options["stimulus"]
end

def typescript?
options["typescript"] || ViewComponent::Base.config.generate.typescript
options["typescript"]
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ class ComponentGenerator < Rails::Generators::NamedBase
class_option :locale, type: :boolean, default: ViewComponent::Base.config.generate.locale
class_option :parent, type: :string, desc: "The parent class for the generated component"
class_option :preview, type: :boolean, default: ViewComponent::Base.config.generate.preview
class_option :sidecar, type: :boolean, default: false
class_option :stimulus, type: :boolean,
default: ViewComponent::Base.config.generate.stimulus_controller
class_option :sidecar, type: :boolean
class_option :stimulus, type: :boolean
class_option :typescript, type: :boolean

def check_class_collision
class_collisions component_class_name
Expand Down
4 changes: 2 additions & 2 deletions lib/generators/view_component/erb/erb_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ class ErbGenerator < Rails::Generators::NamedBase
include ViewComponent::AbstractGenerator

source_root File.expand_path("templates", __dir__)
class_option :sidecar, type: :boolean, default: false
class_option :sidecar, type: :boolean
class_option :inline, type: :boolean, default: false
class_option :call, type: :boolean, default: false
class_option :stimulus, type: :boolean, default: false
class_option :stimulus, type: :boolean

def engine_name
"erb"
Expand Down
2 changes: 1 addition & 1 deletion lib/generators/view_component/haml/haml_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class HamlGenerator < ViewComponent::Generators::ErbGenerator
include ViewComponent::AbstractGenerator

source_root File.expand_path("templates", __dir__)
class_option :sidecar, type: :boolean, default: false
class_option :sidecar, type: :boolean

def engine_name
"haml"
Expand Down
2 changes: 1 addition & 1 deletion lib/generators/view_component/locale/locale_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class LocaleGenerator < ::Rails::Generators::NamedBase

source_root File.expand_path("templates", __dir__)
argument :attributes, type: :array, default: [], banner: "attribute"
class_option :sidecar, type: :boolean, default: false
class_option :sidecar, type: :boolean

def create_locale_file
if ViewComponent::Base.config.generate.distinct_locale_files
Expand Down
2 changes: 1 addition & 1 deletion lib/generators/view_component/slim/slim_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class SlimGenerator < ViewComponent::Generators::ErbGenerator
include ViewComponent::AbstractGenerator

source_root File.expand_path("templates", __dir__)
class_option :sidecar, type: :boolean, default: false
class_option :sidecar, type: :boolean

def engine_name
"slim"
Expand Down
4 changes: 2 additions & 2 deletions lib/generators/view_component/stimulus/stimulus_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ class StimulusGenerator < ::Rails::Generators::NamedBase
include ViewComponent::AbstractGenerator

source_root File.expand_path("templates", __dir__)
class_option :sidecar, type: :boolean, default: false
class_option :typescript, type: :boolean, default: false
class_option :sidecar, type: :boolean
class_option :typescript, type: :boolean

def create_stimulus_controller
template "component_controller.#{filetype}", destination
Expand Down
2 changes: 1 addition & 1 deletion lib/view_component/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class << self
def cache_digest(finder: nil, format: :html)
ViewComponent::CacheDigest.digest(
self,
finder: finder || ViewComponent::CacheDigest.default_finder,
finder: finder,
format: format
)
end
Expand Down
61 changes: 29 additions & 32 deletions lib/view_component/cache_digest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,7 @@ def compiled_source(template)
# @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
# components and partial virtual paths
def renders_in(source, name, view_paths: nil, nesting: [])
names = []
other_calls = []

each_node(Prism.parse(source).value, nesting) do |node, scope|
if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
names << [node.slice, scope] if CONSTANT_NAME.match?(node.slice)
elsif render_call?(node) && !rendered_component(node, scope)
other_calls << node
end
end

components = names.uniq.filter_map { |constant_name, scope| constantize_component(constant_name, scope) }
components, _component_calls, other_calls = partition_render_calls(Prism.parse(source).value, nesting)
partials = partials_rendered_by(other_calls, name) - unresolved_partials_rendered_by(other_calls, name, view_paths)
[component_paths(components), partials]
end
Expand All @@ -157,11 +146,11 @@ def renders_in(source, name, view_paths: nil, nesting: [])
# @return [Array(Array<String>, Array<String>)] synthetic virtual paths of
# components and inferred partial virtual paths
def template_renders_in(source, name, view_paths: nil)
components, other_calls = partition_render_calls(Prism.parse(source).value)
components, component_calls, other_calls = partition_render_calls(Prism.parse(source).value)

[
component_paths(components.values),
(partials_rendered_by(components.keys, name) - partials_rendered_by(other_calls, name)) |
component_paths(components),
(partials_rendered_by(component_calls, name) - partials_rendered_by(other_calls, name)) |
unresolved_partials_rendered_by(other_calls, name, view_paths)
]
end
Expand Down Expand Up @@ -214,24 +203,28 @@ def explicit_component_dependencies(source)
# Compute the digest of a component using Rails' digest tree.
#
# @param component [Class] a component class
# @param finder [ActionView::LookupContext]
# @param finder [ActionView::LookupContext, nil]
# @param format [Symbol]
#
# @return [String]
def digest(component, finder: default_finder, format: :html)
def digest(component, finder: nil, format: :html)
register(component)
virtual_path = virtual_path_for(component)
return "" unless virtual_path

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

# A lookup context for digesting components outside a request, where no
# view context (and therefore no finder) exists.
#
# @param format [Symbol]
#
# @return [ActionView::LookupContext]
def default_finder
def default_finder(format: :html)
# Not memoized across reloads: view paths change when the app reloads.
ActionView::LookupContext.new(ActionController::Base.view_paths)
ActionView::LookupContext.new(ActionController::Base.view_paths, formats: [format])
end

# Wire the tracker and resolver into Action View.
Expand Down Expand Up @@ -293,25 +286,29 @@ def precompute!(components)

private

# Receiverless `render` and `render_to_string` calls, split into those
# that render a component and the rest.
# Component references and receiverless render calls, split into those
# that render a component directly and the rest.
#
# @return [Array(Hash{Prism::CallNode => Class}, Array<Prism::CallNode>)]
def partition_render_calls(program)
components = {}
# @return [Array(Array<Class>, Array<Prism::CallNode>, Array<Prism::CallNode>)]
def partition_render_calls(program, nesting = [])
names = []
component_calls = []
other_calls = []

each_node(program) do |node, nesting|
next unless render_call?(node)

if (component = rendered_component(node, nesting))
components[node] = component
else
other_calls << node
each_node(program, nesting) do |node, scope|
if node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
names << [node.slice, scope] if CONSTANT_NAME.match?(node.slice)
elsif render_call?(node)
if rendered_component(node, scope)
component_calls << node
else
other_calls << node
end
end
end

[components, other_calls]
components = names.uniq.filter_map { |constant_name, scope| constantize_component(constant_name, scope) }
[components, component_calls, other_calls]
end

def render_call?(node)
Expand Down
12 changes: 9 additions & 3 deletions lib/view_component/cacheable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,17 @@ def __vc_caches_output?
# The cache key for this rendering of the component.
#
# Combines the component's identity, its source digest, the requested
# ordered formats and variants, the current locale, and the values declared with
# `cache_on`. Override for full control.
# ordered formats, variants and partial lookup prefixes, the current locale,
# and the values declared with `cache_on`. Override for full control.
#
# @param view_context [ActionView::Base]
# @return [String]
def cache_key(view_context = nil)
lookup_context = view_context&.lookup_context
formats = Array(lookup_context&.formats).presence || [:html]
values = __vc_cache_on_values.map do |value|
value.nil? ? nil : ActiveSupport::Cache.expand_cache_key(value)
end

parts = [
"view_component",
Expand All @@ -135,8 +138,11 @@ def cache_key(view_context = nil)
# Encode each list to keep format and variant boundaries distinct.
formats.to_json,
__vc_cache_variants(lookup_context).to_json,
Array(lookup_context&.prefixes).to_json,
I18n.locale,
*__vc_cache_on_values
# Expand records using Rails' versioned keys, then preserve the boundaries
# between declared values, including nils and strings containing slashes.
values.to_json
]

# Positions are significant, so nils are substituted rather than removed.
Expand Down
Loading
Loading