From 3cf84b1e4076e78d5f64e6938d7be22db3b2d313 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 3 Sep 2024 15:03:56 -0600 Subject: [PATCH 001/158] add v4 changelog heading --- docs/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9d44e5454..f540c6f7c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,8 @@ nav_order: 5 ## main +## 4.0.0 + * Add basic internal testing for memory allocations. *Joel Hawksley* From 081f5b2ac2c0b13e832dea9b4f7d26333dc7e247 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 4 Sep 2024 10:59:36 -0600 Subject: [PATCH 002/158] Minimum Ruby version is non-EOL per https://www.ruby-lang.org/en/downloads/branches/ (#2086) --- .github/workflows/ci.yml | 4 ++-- docs/CHANGELOG.md | 4 ++++ view_component.gemspec | 12 ++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0e94f1df..b44d2027c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,10 +27,10 @@ jobs: fail-fast: false matrix: include: - - ruby_version: "3.0" + - ruby_version: "3.1" rails_version: "6.1" mode: "capture_patch_enabled" - - ruby_version: "3.0" + - ruby_version: "3.1" rails_version: "6.1" mode: "capture_patch_disabled" - ruby_version: "3.1" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f540c6f7c..5490d1681 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ nav_order: 5 ## 4.0.0 +* BREAKING: Require [non-EOL](https://www.ruby-lang.org/en/downloads/branches/) Ruby (`>= 3.1.0`). + + * Joel Hawksley* + * Add basic internal testing for memory allocations. *Joel Hawksley* diff --git a/view_component.gemspec b/view_component.gemspec index 03fc32c77..0e20155bd 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |spec| spec.files = Dir["LICENSE.txt", "README.md", "app/**/*", "docs/CHANGELOG.md", "lib/**/*"] spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 2.7.0" + spec.required_ruby_version = ">= 3.1.0" spec.add_runtime_dependency "activesupport", [">= 5.2.0", "< 8.0"] spec.add_runtime_dependency "method_source", "~> 1.0" @@ -61,11 +61,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "yard", "~> 0.9.34" spec.add_development_dependency "yard-activesupport-concern", "~> 0.0.1" - if RUBY_VERSION >= "3.1" - spec.add_development_dependency "net-imap" - spec.add_development_dependency "net-pop" - spec.add_development_dependency "net-smtp" - else - spec.post_install_message = "ViewComponent v4 will drop support for Ruby < 3.1 in 2025." - end + spec.add_development_dependency "net-imap" + spec.add_development_dependency "net-pop" + spec.add_development_dependency "net-smtp" end From aa7f44a339120c5bb3cd96ce356508ecbe7e271a Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 6 Sep 2024 14:18:14 -0600 Subject: [PATCH 003/158] only support Rails 7+ (#2089) --- .github/workflows/ci.yml | 6 ------ Gemfile.lock | 2 +- docs/CHANGELOG.md | 4 ++++ view_component.gemspec | 2 +- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b44d2027c..cb5161b37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,6 @@ jobs: fail-fast: false matrix: include: - - ruby_version: "3.1" - rails_version: "6.1" - mode: "capture_patch_enabled" - - ruby_version: "3.1" - rails_version: "6.1" - mode: "capture_patch_disabled" - ruby_version: "3.1" rails_version: "7.0" mode: "capture_patch_enabled" diff --git a/Gemfile.lock b/Gemfile.lock index d688d4cfb..f9d2c6dca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: view_component (3.14.0) - activesupport (>= 5.2.0, < 8.0) + activesupport (>= 7.0.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5490d1681..249520662 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ nav_order: 5 ## 4.0.0 +* BREAKING: Require [non-EOL](https://endoflife.date/rails) Rails (`>= 7.0.0`). + + *Joel Hawksley* + * BREAKING: Require [non-EOL](https://www.ruby-lang.org/en/downloads/branches/) Ruby (`>= 3.1.0`). * Joel Hawksley* diff --git a/view_component.gemspec b/view_component.gemspec index 0e20155bd..3037ca321 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -29,7 +29,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.1.0" - spec.add_runtime_dependency "activesupport", [">= 5.2.0", "< 8.0"] + spec.add_runtime_dependency "activesupport", [">= 7.0.0", "< 8.0"] spec.add_runtime_dependency "method_source", "~> 1.0" spec.add_runtime_dependency "concurrent-ruby", "~> 1.0" spec.add_development_dependency "allocation_stats", "~> 0.1.5" From bc4a193adc3d27b78e96eec50238f3b0f8595020 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 26 Sep 2024 12:52:57 -0600 Subject: [PATCH 004/158] [v4] Only support current Rails and Ruby (#2101) * Minimum Ruby version is non-EOL per https://www.ruby-lang.org/en/downloads/branches/ (#2086) * Only support current Rails and Ruby In further review of the end-of-life dates for Ruby and Rails, I propose that we target Ruby >= 3.2 and Rails >= 7.1 which will be the lowest actively supported versions on April 1, 2025. We can ship v4 on that date. * remove backwards compat carve-out * docs update * merge cleanup * update CI config * remove rails version warning * grammar --- .github/workflows/ci.yml | 6 -- Gemfile | 2 +- Gemfile.lock | 152 ++++++++++++++++++++++----------------- docs/CHANGELOG.md | 6 +- docs/compatibility.md | 4 +- lib/view_component.rb | 4 -- view_component.gemspec | 4 +- 7 files changed, 93 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb5161b37..668381c58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,6 @@ jobs: fail-fast: false matrix: include: - - ruby_version: "3.1" - rails_version: "7.0" - mode: "capture_patch_enabled" - - ruby_version: "3.1" - rails_version: "7.0" - mode: "capture_patch_disabled" - ruby_version: "3.2" rails_version: "7.1" mode: "capture_patch_enabled" diff --git a/Gemfile b/Gemfile index 0a20ee868..0ed4f7ae3 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source "https://rubygems.org" gemspec -rails_version = (ENV["RAILS_VERSION"] || "~> 7.0.0").to_s +rails_version = (ENV["RAILS_VERSION"] || "~> 7.1.0").to_s gem "rails", (rails_version == "main") ? {git: "https://github.com/rails/rails", ref: "main"} : rails_version ruby_version = (ENV["RUBY_VERSION"] || "~> 3.3").to_s diff --git a/Gemfile.lock b/Gemfile.lock index f9d2c6dca..e35c474bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,77 +2,86 @@ PATH remote: . specs: view_component (3.14.0) - activesupport (>= 7.0.0, < 8.0) + activesupport (>= 7.1.0, < 8.0) concurrent-ruby (~> 1.0) method_source (~> 1.0) GEM remote: https://rubygems.org/ specs: - actioncable (7.0.8.4) - actionpack (= 7.0.8.4) - activesupport (= 7.0.8.4) + actioncable (7.1.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (7.0.8.4) - actionpack (= 7.0.8.4) - activejob (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + zeitwerk (~> 2.6) + actionmailbox (7.1.4) + actionpack (= 7.1.4) + activejob (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.0.8.4) - actionpack (= 7.0.8.4) - actionview (= 7.0.8.4) - activejob (= 7.0.8.4) - activesupport (= 7.0.8.4) + actionmailer (7.1.4) + actionpack (= 7.1.4) + actionview (= 7.1.4) + activejob (= 7.1.4) + activesupport (= 7.1.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp - rails-dom-testing (~> 2.0) - actionpack (7.0.8.4) - actionview (= 7.0.8.4) - activesupport (= 7.0.8.4) - rack (~> 2.0, >= 2.2.4) + rails-dom-testing (~> 2.2) + actionpack (7.1.4) + actionview (= 7.1.4) + activesupport (= 7.1.4) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (7.0.8.4) - actionpack (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.4) + actionpack (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.0.8.4) - activesupport (= 7.0.8.4) + actionview (7.1.4) + activesupport (= 7.1.4) builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (7.0.8.4) - activesupport (= 7.0.8.4) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.4) + activesupport (= 7.1.4) globalid (>= 0.3.6) - activemodel (7.0.8.4) - activesupport (= 7.0.8.4) - activerecord (7.0.8.4) - activemodel (= 7.0.8.4) - activesupport (= 7.0.8.4) - activestorage (7.0.8.4) - actionpack (= 7.0.8.4) - activejob (= 7.0.8.4) - activerecord (= 7.0.8.4) - activesupport (= 7.0.8.4) + activemodel (7.1.4) + activesupport (= 7.1.4) + activerecord (7.1.4) + activemodel (= 7.1.4) + activesupport (= 7.1.4) + timeout (>= 0.4.0) + activestorage (7.1.4) + actionpack (= 7.1.4) + activejob (= 7.1.4) + activerecord (= 7.1.4) + activesupport (= 7.1.4) marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (7.0.8.4) + activesupport (7.1.4) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) @@ -83,6 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.2) + base64 (0.2.0) benchmark-ips (2.13.0) better_html (2.1.1) actionview (>= 6.0) @@ -91,6 +101,7 @@ GEM erubi (~> 1.4) parser (>= 2.4) smart_properties + bigdecimal (3.1.8) builder (3.3.0) capybara (3.40.0) addressable @@ -103,6 +114,7 @@ GEM xpath (~> 3.2) coderay (1.1.3) concurrent-ruby (1.3.4) + connection_pool (2.4.1) crass (1.0.6) cuprite (0.15.1) capybara (~> 3.0) @@ -113,6 +125,7 @@ GEM reline (>= 0.3.8) diff-lcs (1.5.1) docile (1.4.1) + drb (2.2.1) erb_lint (0.6.0) activesupport better_html (>= 2.0.1) @@ -161,6 +174,7 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.7) minitest (5.25.1) + mutex_m (0.2.0) net-imap (0.4.16) date net-protocol @@ -188,22 +202,27 @@ GEM nio4r (~> 2.0) racc (1.8.1) rack (2.2.9) + rack-session (1.0.2) + rack (< 3) rack-test (2.1.0) rack (>= 1.3) - rails (7.0.8.4) - actioncable (= 7.0.8.4) - actionmailbox (= 7.0.8.4) - actionmailer (= 7.0.8.4) - actionpack (= 7.0.8.4) - actiontext (= 7.0.8.4) - actionview (= 7.0.8.4) - activejob (= 7.0.8.4) - activemodel (= 7.0.8.4) - activerecord (= 7.0.8.4) - activestorage (= 7.0.8.4) - activesupport (= 7.0.8.4) + rackup (1.0.0) + rack (< 3) + webrick + rails (7.1.4) + actioncable (= 7.1.4) + actionmailbox (= 7.1.4) + actionmailer (= 7.1.4) + actionpack (= 7.1.4) + actiontext (= 7.1.4) + actionview (= 7.1.4) + activejob (= 7.1.4) + activemodel (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) bundler (>= 1.15.0) - railties (= 7.0.8.4) + railties (= 7.1.4) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -211,13 +230,14 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - railties (7.0.8.4) - actionpack (= 7.0.8.4) - activesupport (= 7.0.8.4) - method_source + railties (7.1.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) + irb + rackup (>= 1.0.0) rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) rdoc (6.7.0) @@ -349,7 +369,7 @@ DEPENDENCIES net-smtp pry (~> 0.13) puma (~> 6) - rails (~> 7.0.0) + rails (~> 7.1.0) rake (~> 13.0) rspec-rails (~> 5) rubocop-md (~> 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 249520662..c81fe4dd6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,13 +12,13 @@ nav_order: 5 ## 4.0.0 -* BREAKING: Require [non-EOL](https://endoflife.date/rails) Rails (`>= 7.0.0`). +* BREAKING: Require [non-EOL](https://endoflife.date/rails) Rails (`>= 7.1.0`). *Joel Hawksley* -* BREAKING: Require [non-EOL](https://www.ruby-lang.org/en/downloads/branches/) Ruby (`>= 3.1.0`). +* BREAKING: Require [non-EOL](https://www.ruby-lang.org/en/downloads/branches/) Ruby (`>= 3.2.0`). - * Joel Hawksley* + *Joel Hawksley* * Add basic internal testing for memory allocations. diff --git a/docs/compatibility.md b/docs/compatibility.md index ded7dfd13..eabbdb767 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -8,9 +8,7 @@ nav_order: 6 ## Ruby & Rails -ViewComponent supports all actively supported versions of Ruby (3.0+) and Ruby on Rails (6.1+) and is tested against a combination of these versions of Ruby on Rails. - -While EOL (end-of-life) versions of Ruby and Ruby on Rails may still work with ViewComponent, they're not actively supported and no longer tested. We will still accept patches on a case-by-case basis to support older Ruby & Rails versions based on the complexity and maintenance burden. Please open an issue before submitting such a Pull Request. +ViewComponent supports all actively supported versions of [Ruby](https://endoflife.date/ruby) (>= 3.2) and [Ruby on Rails](https://endoflife.date/rails) (>= 7.1). Changes to the minimum Ruby and Rails versions supported will only be made in major releases. ## Template languages diff --git a/lib/view_component.rb b/lib/view_component.rb index 4327775e6..62c9cd2cb 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -21,10 +21,6 @@ module ViewComponent autoload :TestCase autoload :SystemTestCase autoload :Translatable - - if defined?(Rails) && Rails.version < "7.0" - Kernel.warn("ViewComponent v4 will drop support for Rails < 7.0 in 2025.") - end end require "view_component/engine" if defined?(Rails::Engine) diff --git a/view_component.gemspec b/view_component.gemspec index 3037ca321..24e7e5945 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -27,9 +27,9 @@ Gem::Specification.new do |spec| spec.files = Dir["LICENSE.txt", "README.md", "app/**/*", "docs/CHANGELOG.md", "lib/**/*"] spec.require_paths = ["lib"] - spec.required_ruby_version = ">= 3.1.0" + spec.required_ruby_version = ">= 3.2.0" - spec.add_runtime_dependency "activesupport", [">= 7.0.0", "< 8.0"] + spec.add_runtime_dependency "activesupport", [">= 7.1.0", "< 8.0"] spec.add_runtime_dependency "method_source", "~> 1.0" spec.add_runtime_dependency "concurrent-ruby", "~> 1.0" spec.add_development_dependency "allocation_stats", "~> 0.1.5" From a16e4fe53f0308c5476ea6fdbcf25650c81d5e50 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 26 Sep 2024 15:32:21 -0600 Subject: [PATCH 005/158] [v4] Remove code paths for unsupported versions of Rails (#2103) * remove appraisals for unsupported rails versions * remove accommodations for Rails < 6.1 in PreviewHelper * remove support for Rails < 6.1 in Preview layout * remove old gemfiles * remove known issue docs for version of rails no longer supported * remove old rails support in test suite * simplify render_inline to only support modern Rails * always include collection_renderer * remove ruby and rails deprecation warnings in engine.rb * remove render_monkey_patch * remove render monkey patch * update rendering test for rails and ruby deprecations * remove duplicate components --- Appraisals | 17 --- app/helpers/preview_helper.rb | 40 +---- app/views/view_components/preview.html.erb | 6 +- docs/CHANGELOG.md | 4 + docs/api.md | 6 - docs/compatibility.md | 15 -- docs/known_issues.md | 4 - gemfiles/rails_6.1.gemfile | 12 -- gemfiles/rails_7.0.gemfile | 9 -- lib/view_component/base.rb | 7 - lib/view_component/collection.rb | 2 +- lib/view_component/config.rb | 7 - lib/view_component/engine.rb | 51 ------- lib/view_component/preview.rb | 2 - lib/view_component/render_component_helper.rb | 10 -- .../render_component_to_string_helper.rb | 9 -- lib/view_component/render_monkey_patch.rb | 13 -- .../render_to_string_monkey_patch.rb | 13 -- .../rendering_component_helper.rb | 9 -- lib/view_component/rendering_monkey_patch.rb | 13 -- lib/view_component/test_helpers.rb | 11 +- .../app/components/container_component.rb | 6 +- .../monkey_patch_disabled_component.html.erb | 1 - .../monkey_patch_disabled_component.rb | 4 - .../integration_examples_controller.rb | 10 -- .../render_component.html.erb | 1 - test/sandbox/config/environments/test.rb | 3 +- test/sandbox/config/routes.rb | 3 - ...monkey_patch_disabled_component_preview.rb | 7 - test/sandbox/test/config_test.rb | 1 - test/sandbox/test/integration_test.rb | 54 +------ test/sandbox/test/layouts_test.rb | 50 +++---- test/sandbox/test/preview_helper_test.rb | 139 ------------------ test/sandbox/test/rendering_test.rb | 12 +- test/test_engine/test/config_test.rb | 1 - test/test_helper.rb | 4 - 36 files changed, 43 insertions(+), 513 deletions(-) delete mode 100644 gemfiles/rails_6.1.gemfile delete mode 100644 gemfiles/rails_7.0.gemfile delete mode 100644 lib/view_component/render_component_helper.rb delete mode 100644 lib/view_component/render_component_to_string_helper.rb delete mode 100644 lib/view_component/render_monkey_patch.rb delete mode 100644 lib/view_component/render_to_string_monkey_patch.rb delete mode 100644 lib/view_component/rendering_component_helper.rb delete mode 100644 lib/view_component/rendering_monkey_patch.rb delete mode 100644 test/sandbox/app/components/monkey_patch_disabled_component.html.erb delete mode 100644 test/sandbox/app/components/monkey_patch_disabled_component.rb delete mode 100644 test/sandbox/app/views/integration_examples/render_component.html.erb delete mode 100644 test/sandbox/test/components/previews/monkey_patch_disabled_component_preview.rb diff --git a/Appraisals b/Appraisals index 97a25f8e0..295bcd949 100644 --- a/Appraisals +++ b/Appraisals @@ -1,22 +1,5 @@ # frozen_string_literal: true -appraise "rails-6.1" do - gem "rails", "~> 6.1" - gem "tailwindcss-rails", "~> 2.0" - - # Required for Ruby 3.1.0 - gem "net-smtp", require: false - gem "net-imap", require: false - gem "net-pop", require: false - gem "turbo-rails", "~> 1" -end - -appraise "rails-7.0" do - gem "rails", "~> 7.0" - gem "tailwindcss-rails", "~> 2.0" - gem "turbo-rails", "~> 1" -end - appraise "rails-7.1" do gem "rails", "~> 7.1" gem "tailwindcss-rails", "~> 2.0" diff --git a/app/helpers/preview_helper.rb b/app/helpers/preview_helper.rb index 18016ff76..a679e3318 100644 --- a/app/helpers/preview_helper.rb +++ b/app/helpers/preview_helper.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true module PreviewHelper - # :nocov: - include ActionView::Helpers::AssetUrlHelper if Rails.version.to_f < 6.1 - # :nocov: - AVAILABLE_PRISM_LANGUAGES = %w[ruby erb haml] FALLBACK_LANGUAGE = "ruby" @@ -25,38 +21,10 @@ def prism_js_source_url def find_template_data(lookup_context:, template_identifier:) template = lookup_context.find_template(template_identifier) - if Rails.version.to_f >= 6.1 || template.source.present? - { - source: template.source, - prism_language_name: prism_language_name_by_template(template: template) - } - # :nocov: - else - # Fetch template source via finding it through preview paths - # to accomodate source view when exclusively using templates - # for previews for Rails < 6.1. - all_template_paths = ViewComponent::Base.config.preview_paths.map do |preview_path| - Dir.glob("#{preview_path}/**/*") - end.flatten - - # Search for templates the contain `html`. - matching_templates = all_template_paths.find_all do |path| - path =~ /#{template_identifier}*.(html)/ - end - - raise ViewComponent::NoMatchingTemplatesForPreviewError.new(template_identifier) if matching_templates.empty? - raise ViewComponent::MultipleMatchingTemplatesForPreviewError.new(template_identifier) if matching_templates.size > 1 - - template_file_path = matching_templates.first - template_source = File.read(template_file_path) - prism_language_name = prism_language_name_by_template_path(template_file_path: template_file_path) - - { - source: template_source, - prism_language_name: prism_language_name - } - end - # :nocov: + { + source: template.source, + prism_language_name: prism_language_name_by_template(template: template) + } end private diff --git a/app/views/view_components/preview.html.erb b/app/views/view_components/preview.html.erb index f364fd725..c12aa6eb0 100644 --- a/app/views/view_components/preview.html.erb +++ b/app/views/view_components/preview.html.erb @@ -1,9 +1,5 @@ <% if @render_args[:component] %> - <% if ViewComponent::Base.config.render_monkey_patch_enabled || Rails.version.to_f >= 6.1 %> - <%= render(@render_args[:component], @render_args[:args], &@render_args[:block]) %> - <% else %> - <%= render_component(@render_args[:component], &@render_args[:block]) %> - <% end %> + <%= render(@render_args[:component], @render_args[:args], &@render_args[:block]) %> <% else %> <%= render template: @render_args[:template], locals: @render_args[:locals] || {} %> <% end %> diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c81fe4dd6..cbaf13d05 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,10 @@ nav_order: 5 *Joel Hawksley* +* BREAKING: Remove `render_component` and `render` monkey patch configured with `render_monkey_patch_enabled`. + + *Joel Hawksley* + * Add basic internal testing for memory allocations. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index e2d5f3b18..87bd7aeaa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -243,12 +243,6 @@ Defaults to `['test/component/previews']` relative to your Rails root. The entry route for component previews. Defaults to `"/rails/view_components"`. -### `.render_monkey_patch_enabled` - -If this is disabled, use `#render_component` or -`#render_component_to_string` instead. -Defaults to `true`. - ### `.show_previews` Whether component previews are enabled. diff --git a/docs/compatibility.md b/docs/compatibility.md index eabbdb767..19f243267 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -14,21 +14,6 @@ ViewComponent supports all actively supported versions of [Ruby](https://endofli ViewComponent is tested against ERB, Haml, and Slim, but it should support most Rails template handlers. -## Disabling the render monkey patch (Rails < 6.1) - -Since 2.13.0 -{: .label } - -To [avoid conflicts](https://github.com/viewcomponent/view_component/issues/288) between ViewComponent and other gems that also monkey patch the `render` method, it's possible to configure ViewComponent to not include the render monkey patch: - -`config.view_component.render_monkey_patch_enabled = false # defaults to true` - -With the monkey patch disabled, use `render_component` (or `render_component_to_string`) instead: - -```erb -<%= render_component Component.new(message: "bar") %> -``` - ## Bridgetown (Static Site Generator) [Bridgetown](https://www.bridgetownrb.com/) supports ViewComponent via an experimental shim provided by the [bridgetown-view-component gem](https://github.com/bridgetownrb/bridgetown-view-component). More information available [here](https://www.bridgetownrb.com/docs/components/ruby#need-compatibility-with-rails-try-viewcomponent-experimental). diff --git a/docs/known_issues.md b/docs/known_issues.md index ce20d6abf..ff8197168 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -55,7 +55,3 @@ Calls to form helpers such as `form_with` in ViewComponents [don't use the defau <%= f.text_field :name %> <% end %> ``` - -## Inconsistent controller rendering behavior between Rails versions - -In versions of Rails < 6.1, rendering a ViewComponent from a controller doesn't include the layout. diff --git a/gemfiles/rails_6.1.gemfile b/gemfiles/rails_6.1.gemfile deleted file mode 100644 index d78c28030..000000000 --- a/gemfiles/rails_6.1.gemfile +++ /dev/null @@ -1,12 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rails", "~> 6.1" -gem "tailwindcss-rails", "~> 2.0" -gem "net-smtp", require: false -gem "net-imap", require: false -gem "net-pop", require: false -gem "turbo-rails", "~> 1" - -gemspec path: "../" diff --git a/gemfiles/rails_7.0.gemfile b/gemfiles/rails_7.0.gemfile deleted file mode 100644 index a703d2cc6..000000000 --- a/gemfiles/rails_7.0.gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "rails", "~> 7.0" -gem "tailwindcss-rails", "~> 2.0" -gem "turbo-rails", "~> 1" - -gemspec path: "../" diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 4422c2bd3..59ee399a1 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -365,13 +365,6 @@ def compiler # configured on a per-test basis using `with_controller_class`. # - # Set if render monkey patches should be included or not in Rails <6.1: - # - # ```ruby - # config.view_component.render_monkey_patch_enabled = false - # ``` - # - # Path for component files # # ```ruby diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index e4082dc0d..3ff0b9fc1 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "action_view/renderer/collection_renderer" if Rails.version.to_f >= 6.1 +require "action_view/renderer/collection_renderer" module ViewComponent class Collection diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index d2dd5a863..c447dd7da 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -18,7 +18,6 @@ def defaults show_previews_source: false, instrumentation_enabled: false, use_deprecated_instrumentation_name: true, - render_monkey_patch_enabled: true, view_component_path: "app/components", component_parent_class: nil, show_previews: Rails.env.development? || Rails.env.test?, @@ -120,12 +119,6 @@ def defaults # Will default to `false` in next major version. # Defaults to `true`. - # @!attribute render_monkey_patch_enabled - # @return [Boolean] Whether the #render method should be monkey patched. - # If this is disabled, use `#render_component` or - # `#render_component_to_string` instead. - # Defaults to `true`. - # @!attribute view_component_path # @return [String] # The path in which components, their templates, and their sidecars should diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index 9a52c661f..fe91377ba 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -27,7 +27,6 @@ class Engine < Rails::Engine # :nodoc: options[config_option] ||= ViewComponent::Base.public_send(config_option) end options.instrumentation_enabled = false if options.instrumentation_enabled.nil? - options.render_monkey_patch_enabled = true if options.render_monkey_patch_enabled.nil? options.show_previews = (Rails.env.development? || Rails.env.test?) if options.show_previews.nil? if options.show_previews @@ -85,46 +84,6 @@ class Engine < Rails::Engine # :nodoc: end end - initializer "view_component.monkey_patch_render" do |app| - next if Rails.version.to_f >= 6.1 || !app.config.view_component.render_monkey_patch_enabled - - # :nocov: - ViewComponent::Deprecation.deprecation_warning("Monkey patching `render`", "ViewComponent 4.0 will remove the `render` monkey patch") - - ActiveSupport.on_load(:action_view) do - require "view_component/render_monkey_patch" - ActionView::Base.prepend ViewComponent::RenderMonkeyPatch - end - - ActiveSupport.on_load(:action_controller) do - require "view_component/rendering_monkey_patch" - require "view_component/render_to_string_monkey_patch" - ActionController::Base.prepend ViewComponent::RenderingMonkeyPatch - ActionController::Base.prepend ViewComponent::RenderToStringMonkeyPatch - end - # :nocov: - end - - initializer "view_component.include_render_component" do |_app| - next if Rails.version.to_f >= 6.1 - - # :nocov: - ViewComponent::Deprecation.deprecation_warning("using `render_component`", "ViewComponent 4.0 will remove `render_component`") - - ActiveSupport.on_load(:action_view) do - require "view_component/render_component_helper" - ActionView::Base.include ViewComponent::RenderComponentHelper - end - - ActiveSupport.on_load(:action_controller) do - require "view_component/rendering_component_helper" - require "view_component/render_component_to_string_helper" - ActionController::Base.include ViewComponent::RenderingComponentHelper - ActionController::Base.include ViewComponent::RenderComponentToStringHelper - end - # :nocov: - end - initializer "static assets" do |app| if serve_static_preview_assets?(app.config) app.middleware.use(::ActionDispatch::Static, "#{root}/app/assets/vendor") @@ -168,16 +127,6 @@ def serve_static_preview_assets?(app_config) end end - # :nocov: - if RUBY_VERSION < "3.0.0" - ViewComponent::Deprecation.deprecation_warning("Support for Ruby versions < 3.0.0", "ViewComponent 4.0 will remove support for Ruby versions < 3.0.0 ") - end - - if Rails.version.to_f < 6.1 - ViewComponent::Deprecation.deprecation_warning("Support for Rails versions < 6.1", "ViewComponent 4.0 will remove support for Rails versions < 6.1 ") - end - # :nocov: - app.executor.to_run :before do CompileCache.invalidate! unless ActionView::Base.cache_template_loading end diff --git a/lib/view_component/preview.rb b/lib/view_component/preview.rb index 2d3c84465..737b3601e 100644 --- a/lib/view_component/preview.rb +++ b/lib/view_component/preview.rb @@ -30,8 +30,6 @@ def render_with_template(template: nil, locals: {}) } end - alias_method :render_component, :render - class << self # Returns all component preview classes. def all diff --git a/lib/view_component/render_component_helper.rb b/lib/view_component/render_component_helper.rb deleted file mode 100644 index 945cd539d..000000000 --- a/lib/view_component/render_component_helper.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - module RenderComponentHelper # :nodoc: - def render_component(component, &block) - component.set_original_view_context(__vc_original_view_context) if is_a?(ViewComponent::Base) - component.render_in(self, &block) - end - end -end diff --git a/lib/view_component/render_component_to_string_helper.rb b/lib/view_component/render_component_to_string_helper.rb deleted file mode 100644 index dff55587c..000000000 --- a/lib/view_component/render_component_to_string_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - module RenderComponentToStringHelper # :nodoc: - def render_component_to_string(component) - component.render_in(view_context) - end - end -end diff --git a/lib/view_component/render_monkey_patch.rb b/lib/view_component/render_monkey_patch.rb deleted file mode 100644 index 0d05ac394..000000000 --- a/lib/view_component/render_monkey_patch.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - module RenderMonkeyPatch # :nodoc: - def render(options = {}, args = {}, &block) - if options.respond_to?(:render_in) - options.render_in(self, &block) - else - super - end - end - end -end diff --git a/lib/view_component/render_to_string_monkey_patch.rb b/lib/view_component/render_to_string_monkey_patch.rb deleted file mode 100644 index 325646f07..000000000 --- a/lib/view_component/render_to_string_monkey_patch.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - module RenderToStringMonkeyPatch # :nodoc: - def render_to_string(options = {}, args = {}) - if options.respond_to?(:render_in) - options.render_in(view_context) - else - super - end - end - end -end diff --git a/lib/view_component/rendering_component_helper.rb b/lib/view_component/rendering_component_helper.rb deleted file mode 100644 index dc7777efb..000000000 --- a/lib/view_component/rendering_component_helper.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - module RenderingComponentHelper # :nodoc: - def render_component(component) - self.response_body = component.render_in(view_context) - end - end -end diff --git a/lib/view_component/rendering_monkey_patch.rb b/lib/view_component/rendering_monkey_patch.rb deleted file mode 100644 index ac7df3380..000000000 --- a/lib/view_component/rendering_monkey_patch.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - module RenderingMonkeyPatch # :nodoc: - def render(options = {}, args = {}) - if options.respond_to?(:render_in) - self.response_body = options.render_in(view_context) - else - super - end - end - end -end diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 3677763fb..58e4e2a4c 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -49,16 +49,7 @@ def assert_component_rendered # @return [Nokogiri::HTML] def render_inline(component, **args, &block) @page = nil - @rendered_content = - if Rails.version.to_f >= 6.1 - vc_test_controller.view_context.render(component, args, &block) - - # :nocov: - else - vc_test_controller.view_context.render_component(component, &block) - end - - # :nocov: + @rendered_content = vc_test_controller.view_context.render(component, args, &block) Nokogiri::HTML.fragment(@rendered_content) end diff --git a/test/sandbox/app/components/container_component.rb b/test/sandbox/app/components/container_component.rb index 471502918..f06f9428d 100644 --- a/test/sandbox/app/components/container_component.rb +++ b/test/sandbox/app/components/container_component.rb @@ -2,10 +2,6 @@ class ContainerComponent < ViewComponent::Base def call - if Rails.application.config.view_component.render_monkey_patch_enabled || Rails.version.to_f >= 6.1 - render HelpersProxyComponent.new - else - render_component HelpersProxyComponent.new - end + render HelpersProxyComponent.new end end diff --git a/test/sandbox/app/components/monkey_patch_disabled_component.html.erb b/test/sandbox/app/components/monkey_patch_disabled_component.html.erb deleted file mode 100644 index d2d68fe70..000000000 --- a/test/sandbox/app/components/monkey_patch_disabled_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -
hello,world!
diff --git a/test/sandbox/app/components/monkey_patch_disabled_component.rb b/test/sandbox/app/components/monkey_patch_disabled_component.rb deleted file mode 100644 index f01f73f31..000000000 --- a/test/sandbox/app/components/monkey_patch_disabled_component.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class MonkeyPatchDisabledComponent < ViewComponent::Base -end diff --git a/test/sandbox/app/controllers/integration_examples_controller.rb b/test/sandbox/app/controllers/integration_examples_controller.rb index 457405351..09f3b92ff 100644 --- a/test/sandbox/app/controllers/integration_examples_controller.rb +++ b/test/sandbox/app/controllers/integration_examples_controller.rb @@ -23,25 +23,15 @@ def controller_inline_baseline end def controller_to_string - # Ensures render_to_string_monkey_patch.rb correctly calls `super` when - # not rendering a component: render_to_string("integration_examples/_controller_inline", locals: {message: "bar"}) render(plain: render_to_string(ControllerInlineComponent.new(message: "bar"))) end - def controller_inline_render_component - render_component(ControllerInlineComponent.new(message: "bar")) - end - def helpers_proxy_component render(plain: render_to_string(HelpersProxyComponent.new)) end - def controller_to_string_render_component - render(plain: render_component_to_string(ControllerInlineComponent.new(message: "bar"))) - end - def products @products = [OpenStruct.new(name: "Radio clock"), OpenStruct.new(name: "Mints")] end diff --git a/test/sandbox/app/views/integration_examples/render_component.html.erb b/test/sandbox/app/views/integration_examples/render_component.html.erb deleted file mode 100644 index 29a63eab4..000000000 --- a/test/sandbox/app/views/integration_examples/render_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render_component ErbComponent.new(message: "bar") %> diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index 43cd3b2d8..1875b2621 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -33,7 +33,6 @@ config.view_component.show_previews = true config.view_component.preview_paths << "#{Rails.root}/lib/component_previews" - config.view_component.render_monkey_patch_enabled = true config.view_component.show_previews_source = true config.view_component.test_controller = "IntegrationExamplesController" config.view_component.capture_compatibility_patch_enabled = ENV["CAPTURE_PATCH_ENABLED"] == "true" @@ -51,7 +50,7 @@ # Print deprecation notices to the stderr config.active_support.deprecation = :stderr - config.action_view.annotate_rendered_view_with_filenames = true if Rails.version.to_f >= 6.1 + config.action_view.annotate_rendered_view_with_filenames = true config.eager_load = true end diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb index 6bdb4525d..f5b7a43a3 100644 --- a/test/sandbox/config/routes.rb +++ b/test/sandbox/config/routes.rb @@ -15,9 +15,6 @@ get :controller_inline_with_block, to: "integration_examples#controller_inline_with_block" get :controller_inline_baseline, to: "integration_examples#controller_inline_baseline" get :controller_to_string, to: "integration_examples#controller_to_string" - get :render_component, to: "integration_examples#render_component" - get :controller_inline_render_component, to: "integration_examples#controller_inline_render_component" - get :controller_to_string_render_component, to: "integration_examples#controller_to_string_render_component" get :layout_default, to: "layouts#default" get :layout_global_for_action, to: "layouts#global_for_action" get :layout_explicit_in_action, to: "layouts#explicit_in_action" diff --git a/test/sandbox/test/components/previews/monkey_patch_disabled_component_preview.rb b/test/sandbox/test/components/previews/monkey_patch_disabled_component_preview.rb deleted file mode 100644 index 54f4269b7..000000000 --- a/test/sandbox/test/components/previews/monkey_patch_disabled_component_preview.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class MonkeyPatchDisabledComponentPreview < ViewComponent::Preview - def default - render_component(MonkeyPatchDisabledComponent.new(title: "Lorem Ipsum")) - end -end diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index 86c662a02..0a74018b4 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -15,7 +15,6 @@ def test_defaults_are_correct assert_equal @config.show_previews_source, false assert_equal @config.instrumentation_enabled, false assert_equal @config.use_deprecated_instrumentation_name, true - assert_equal @config.render_monkey_patch_enabled, true assert_equal @config.show_previews, true assert_equal @config.preview_paths, ["#{Rails.root}/test/components/previews"] end diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 76e65cf4d..f1af164ae 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -14,15 +14,13 @@ def test_rendering_component_in_a_view assert_select("div", "Foo\n bar") end - if Rails.version.to_f >= 6.1 - def test_rendering_component_with_template_annotations_enabled - get "/" - assert_response :success + def test_rendering_component_with_template_annotations_enabled + get "/" + assert_response :success - assert_includes response.body, "BEGIN app/components/erb_component.html.erb" + assert_includes response.body, "BEGIN app/components/erb_component.html.erb" - assert_select("div", "Foo\n bar") - end + assert_select("div", "Foo\n bar") end def test_rendering_component_in_a_controller @@ -493,42 +491,6 @@ def test_renders_empty_slot_without_error assert_response :success end - if Rails.version.to_f >= 6.1 - def test_rendering_component_using_the_render_component_helper_raises_an_error - error = - assert_raises ActionView::Template::Error do - get "/render_component" - end - - matcher = (RUBY_VERSION >= "3.4") ? /undefined method 'render_component'/ : /undefined method `render_component' for/ - assert_match(matcher, error.message) - end - end - - if Rails.version.to_f < 6.1 - def test_rendering_component_using_render_component - get "/render_component" - assert_includes response.body, "bar" - end - - def test_rendering_component_in_a_controller_using_render_component - get "/controller_inline_render_component" - assert_includes response.body, "bar" - end - - def test_rendering_component_in_a_controller_using_render_component_to_string - get "/controller_to_string_render_component" - assert_includes response.body, "bar" - end - - def test_rendering_component_in_preview_using_render_component_and_monkey_patch_disabled - with_render_monkey_patch_config(false) do - get "/rails/view_components/monkey_patch_disabled_component/default" - assert_includes response.body, "
hello,world!
" - end - end - end - def test_renders_the_inline_component_preview_examples_with_default_behaviour_and_with_their_own_templates get "/rails/view_components/inline_component/default" assert_select "input" do @@ -617,8 +579,6 @@ def test_renders_the_inline_component_using_a_non_standard_located_template end def test_renders_an_inline_component_preview_using_a_haml_template - skip if Rails::VERSION::STRING < "6.1" - get "/rails/view_components/inline_component/with_haml" assert_select "h1", "Some HAML here" assert_select "input[name=?]", "name" @@ -631,8 +591,6 @@ def test_returns_404_when_preview_does_not_exist end def test_renders_a_mix_of_haml_and_erb - skip if Rails::VERSION::STRING < "6.1" - get "/nested_haml" assert_response :success assert_select ".foo > .bar > .baz > .quux > .haml-div" @@ -647,8 +605,6 @@ def test_raises_an_error_if_the_template_is_not_present_and_the_render_with_temp end def test_renders_a_preview_template_using_haml_params_from_url_custom_template_and_locals - skip if Rails::VERSION::STRING < "6.1" - get "/rails/view_components/inline_component/with_several_options?form_title=Title from params" assert_select "form" do diff --git a/test/sandbox/test/layouts_test.rb b/test/sandbox/test/layouts_test.rb index 7bba7b10a..9cf7a62f9 100644 --- a/test/sandbox/test/layouts_test.rb +++ b/test/sandbox/test/layouts_test.rb @@ -3,35 +3,33 @@ require "test_helper" class LayoutsTest < ActionDispatch::IntegrationTest - if Rails.version.to_f >= 6.1 - test "rendering default layout" do - get "/layout_default" - assert_response :success - assert_select 'body[data-layout="application"]' - end + test "rendering default layout" do + get "/layout_default" + assert_response :success + assert_select 'body[data-layout="application"]' + end - test "rendering global_for_action" do - get "/layout_global_for_action" - assert_response :success - assert_select 'body[data-layout="global_for_action"]' - end + test "rendering global_for_action" do + get "/layout_global_for_action" + assert_response :success + assert_select 'body[data-layout="global_for_action"]' + end - test "rendering explicit_in_action" do - get "/layout_explicit_in_action" - assert_response :success - assert_select 'body[data-layout="explicit_in_action"]' - end + test "rendering explicit_in_action" do + get "/layout_explicit_in_action" + assert_response :success + assert_select 'body[data-layout="explicit_in_action"]' + end - test "rendering disabled_in_action" do - get "/layout_disabled_in_action" - assert_response :success - assert_select "body", 0 - end + test "rendering disabled_in_action" do + get "/layout_disabled_in_action" + assert_response :success + assert_select "body", 0 + end - test "rendering with_content_for" do - get "/layout_with_content_for" - assert_response :success - assert_select 'body[data-layout="with_content_for"]', "Hello content for\n\n Foo: bar" - end + test "rendering with_content_for" do + get "/layout_with_content_for" + assert_response :success + assert_select 'body[data-layout="with_content_for"]', "Hello content for\n\n Foo: bar" end end diff --git a/test/sandbox/test/preview_helper_test.rb b/test/sandbox/test/preview_helper_test.rb index 5921a525d..b979db27f 100644 --- a/test/sandbox/test/preview_helper_test.rb +++ b/test/sandbox/test/preview_helper_test.rb @@ -49,143 +49,4 @@ def test_returns_template_data_with_template_of_different_languages assert_equal(template_data[:prism_language_name], language) end end - - if Rails.version.to_f < 6.1 - def test_returns_template_data_without_dedicated_template - template_identifier = "preview/template" - expected_source = "<%= PreviewTest %>" - - PreviewHelper::AVAILABLE_PRISM_LANGUAGES.each do |language| - mock_template = Minitest::Mock.new - mock_template.expect(:source, expected_source) - mock_template.expect(:source, expected_source) - mock_template.expect(:identifier, "html.#{language}") - - lookup_context = Minitest::Mock.new - expected_template_path = "some/path/#{template_identifier}.html.haml" - lookup_context.expect(:find_template, mock_template, [template_identifier]) - - mock = Minitest::Mock.new - mock.expect :map, [expected_template_path] - ViewComponent::Base.stub(:preview_paths, mock) do - template_data = PreviewHelper.find_template_data( - lookup_context: lookup_context, - template_identifier: template_identifier - ) - - assert_equal(template_data[:source], expected_source) - assert_equal(template_data[:prism_language_name], language) - end - end - end - - def test_returns_template_data_with_dedicated_template - template_identifier = "preview/template" - expected_source = "<%= PreviewTest %>" - - PreviewHelper::AVAILABLE_PRISM_LANGUAGES.each do |language| - mock_template = Minitest::Mock.new - mock_template.expect(:source, "") - mock_template.expect(:source, "") - - lookup_context = Minitest::Mock.new - expected_template_path = "some/path/#{template_identifier}.html.#{language}" - lookup_context.expect(:find_template, mock_template, [template_identifier]) - - mock = Minitest::Mock.new - mock.expect :map, [expected_template_path] - Rails.application.config.view_component.stub(:preview_paths, mock) do - File.stub(:read, expected_source, [expected_template_path]) do - template_data = PreviewHelper.find_template_data( - lookup_context: lookup_context, - template_identifier: template_identifier - ) - - assert_equal(template_data[:source], expected_source) - assert_equal(template_data[:prism_language_name], language) - end - end - end - end - - def test_raises_with_no_matching_template - template_identifier = "preview/template" - - mock_template = Minitest::Mock.new - mock_template.expect(:source, "") - mock_template.expect(:source, "") - - lookup_context = Minitest::Mock.new - lookup_context.expect(:find_template, mock_template, [template_identifier]) - - mock = Minitest::Mock.new - mock.expect :map, [] - Rails.application.config.view_component.stub :preview_paths, mock do - exception = assert_raises ViewComponent::NoMatchingTemplatesForPreviewError do - PreviewHelper.find_template_data( - lookup_context: lookup_context, - template_identifier: template_identifier - ) - end - - assert_equal("Found 0 matches for templates for #{template_identifier}.", exception.message) - end - end - - def test_raises_with_conflict_in_template_resolution - template_identifier = "preview/template" - - mock_template = Minitest::Mock.new - mock_template.expect(:source, "") - mock_template.expect(:source, "") - - lookup_context = Minitest::Mock.new - lookup_context.expect(:find_template, mock_template, [template_identifier]) - - mock = Minitest::Mock.new - mock.expect :map, [template_identifier + ".html.haml", template_identifier + ".html.erb"] - Rails.application.config.view_component.stub :preview_paths, mock do - exception = assert_raises ViewComponent::MultipleMatchingTemplatesForPreviewError do - PreviewHelper.find_template_data( - lookup_context: lookup_context, - template_identifier: template_identifier - ) - end - - assert_equal("Found multiple templates for #{template_identifier}.", exception.message) - end - end - - def test_prism_css_source_url_with_asset_pipeline - Rails.application.config.public_file_server.stub(:enabled, true) do - if Rails.version.to_f >= 6.1 - assert_equal "/assets/prism.css", PreviewHelper.prism_css_source_url - else - assert_equal "/prism.css", PreviewHelper.prism_css_source_url - end - end - end - - def test_prism_css_source_url_with_no_asset_pipeline - Rails.application.config.public_file_server.stub(:enabled, false) do - assert_equal "https://cdn.jsdelivr.net/npm/prismjs@1.28.0/themes/prism.min.css", PreviewHelper.prism_css_source_url - end - end - - def test_prism_js_source_with_asset_pipeline - Rails.application.config.public_file_server.stub(:enabled, true) do - if Rails.version.to_f >= 6.1 - assert_equal "/assets/prism.min.js", PreviewHelper.prism_js_source_url - else - assert_equal "/prism.min.js", PreviewHelper.prism_js_source_url - end - end - end - - def test_prism_js_source_url_with_no_asset_pipeline - Rails.application.config.public_file_server.stub(:enabled, false) do - assert_equal "https://cdn.jsdelivr.net/npm/prismjs@1.28.0/prism.min.js", PreviewHelper.prism_js_source_url - end - end - end end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index afd4e1ca7..4e0185e91 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -15,7 +15,7 @@ def test_render_inline_allocations ViewComponent::CompileCache.cache.delete(MyComponent) MyComponent.ensure_compiled - assert_allocations("3.4.0" => 107, "3.3.5" => 116, "3.3.0" => 129, "3.2.5" => 115, "3.1.6" => 115, "3.0.7" => 125) do + assert_allocations("3.4.0" => 107, "3.3.5" => 116, "3.3.0" => 125, "3.2.5" => 115) do render_inline(MyComponent.new) end @@ -117,8 +117,6 @@ def test_renders_slim_template end def test_renders_haml_with_html_formatted_slot - skip if Rails::VERSION::STRING < "6.1" - render_inline(HamlHtmlFormattedSlotComponent.new) assert_selector("p", text: "HTML Formatted one") @@ -269,17 +267,9 @@ def test_renders_helper_method_through_proxy def test_renders_helper_method_within_nested_component render_inline(ContainerComponent.new) - assert_text("Hello helper method") end - def test_renders_helper_method_within_nested_component_with_disabled_monkey_patch - with_render_monkey_patch_config(false) do - render_inline(ContainerComponent.new) - assert_text("Hello helper method") - end - end - def test_renders_path_helper render_inline(PathComponent.new) diff --git a/test/test_engine/test/config_test.rb b/test/test_engine/test/config_test.rb index 16258d003..5300cadcd 100644 --- a/test/test_engine/test/config_test.rb +++ b/test/test_engine/test/config_test.rb @@ -15,7 +15,6 @@ def test_defaults_are_correct assert_equal @config.show_previews_source, false assert_equal @config.instrumentation_enabled, false assert_equal @config.use_deprecated_instrumentation_name, true - assert_equal @config.render_monkey_patch_enabled, true assert_equal @config.show_previews, true assert_equal @config.preview_paths, ["#{TestEngine::Engine.root}/test/components/previews"] end diff --git a/test/test_helper.rb b/test/test_helper.rb index 61e9ff33e..5ea189c8d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -169,10 +169,6 @@ def with_default_preview_layout(layout, &block) with_config_option(:default_preview_layout, layout, &block) end -def with_render_monkey_patch_config(enabled, &block) - with_config_option(:render_monkey_patch_enabled, enabled, &block) -end - def with_compiler_development_mode(mode) previous_mode = ViewComponent::Compiler.development_mode ViewComponent::Compiler.development_mode = mode From 5e6c1a86ac173fe3abfe7f33d53438496105b0c7 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 26 Sep 2024 18:39:15 -0600 Subject: [PATCH 006/158] [v4] Remove more Rails version references (#2104) --- lib/view_component/base.rb | 2 -- test/sandbox/config/environments/test.rb | 2 +- test/sandbox/test/base_test.rb | 3 +-- test/sandbox/test/integration_test.rb | 6 ++---- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 59ee399a1..e6a258a2d 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -666,8 +666,6 @@ def splatted_keyword_argument_present? def initialize_parameter_names return attribute_names.map(&:to_sym) if respond_to?(:attribute_names) - return attribute_types.keys.map(&:to_sym) if Rails::VERSION::MAJOR <= 5 && respond_to?(:attribute_types) - initialize_parameters.map(&:last) end diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index 1875b2621..5fab7ad61 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -25,7 +25,7 @@ config.action_controller.perform_caching = false # Raise exceptions instead of rendering exception templates - config.action_dispatch.show_exceptions = (Rails::VERSION::STRING < "7.1") ? false : :none + config.action_dispatch.show_exceptions = :none # Disable request forgery protection in test environment config.action_controller.allow_forgery_protection = false diff --git a/test/sandbox/test/base_test.rb b/test/sandbox/test/base_test.rb index 6a528b74a..703fa7c33 100644 --- a/test/sandbox/test/base_test.rb +++ b/test/sandbox/test/base_test.rb @@ -84,7 +84,6 @@ def test_sidecar_files end def test_does_not_render_additional_newline_with_render_in - skip unless Rails::VERSION::MAJOR >= 7 without_template_annotations do ActionView::Template::Handlers::ERB.strip_trailing_newlines = true rendered_output = Array.new(2) { @@ -93,7 +92,7 @@ def test_does_not_render_additional_newline_with_render_in assert_includes rendered_output, "Hello, world!Hello, world!" end ensure - ActionView::Template::Handlers::ERB.strip_trailing_newlines = false if Rails::VERSION::MAJOR >= 7 + ActionView::Template::Handlers::ERB.strip_trailing_newlines = false end def test_evaled_component diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index f1af164ae..bf567e25b 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -511,25 +511,23 @@ def test_renders_the_inline_component_preview_examples_with_default_behaviour_an end def test_does_not_render_additional_newline - skip unless Rails::VERSION::MAJOR >= 7 without_template_annotations do ActionView::Template::Handlers::ERB.strip_trailing_newlines = true get "/rails/view_components/display_inline_component/with_newline" assert_includes response.body, "Hello, world!Hello, world!" end ensure - ActionView::Template::Handlers::ERB.strip_trailing_newlines = false if Rails::VERSION::MAJOR >= 7 + ActionView::Template::Handlers::ERB.strip_trailing_newlines = false end def test_does_not_render_additional_newline_with_render_in - skip unless Rails::VERSION::MAJOR >= 7 without_template_annotations do ActionView::Template::Handlers::ERB.strip_trailing_newlines = true get "/rails/view_components/display_inline_component/with_newline_render_in" assert_includes response.body, "Hello, world!Hello, world!" end ensure - ActionView::Template::Handlers::ERB.strip_trailing_newlines = false if Rails::VERSION::MAJOR >= 7 + ActionView::Template::Handlers::ERB.strip_trailing_newlines = false end # This test documents a bug that reports an incompatibility with the turbo-rails gem's `turbo_stream` helper. From 71102dda239194dd58c219fdd1bbbc8ec098ce01 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 4 Nov 2024 13:56:56 -0700 Subject: [PATCH 007/158] fix merge --- Gemfile.lock | 2 +- test/sandbox/test/rendering_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1618fcefa..dbd9018b6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,7 @@ PATH remote: . specs: view_component (3.20.0) - activesupport (>= 7.1.0, < 8.0) + activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1.0) method_source (~> 1.0) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index c836dd08a..d52840f85 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -15,7 +15,7 @@ def test_render_inline_allocations ViewComponent::CompileCache.cache.delete(MyComponent) MyComponent.ensure_compiled - assert_allocations("3.4.0" => 109, "3.3.5" => 115, "3.3.0" => 127, "3.2.6" => 114) do + assert_allocations("3.4.0" => 109, "3.3.5" => 115, "3.3.0" => 124, "3.2.6" => 114) do render_inline(MyComponent.new) end From 86b0149abfce3a2f0359fc54bffda05000af730b Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Fri, 8 Nov 2024 04:57:09 +1030 Subject: [PATCH 008/158] Use ActionView::TemplateDetails for handling format and variant (#2156) * add .DS_Store to gitignore * Add Template subclasses to improve compiler polymorphism * Move template type-specific logic to constructors * Inline source into templates that require it * Flatten inline_call conditional in compile_to_component * Remove defined_on_self param from non-inline-call templates * Use ActionView logic for parsing template names Removes support for variant names containing `.`. * Delegate template format and variant to TemplateDetails --------- Co-authored-by: Joel Hawksley --- .gitignore | 1 + docs/CHANGELOG.md | 4 + docs/index.md | 1 + lib/view_component/compiler.rb | 34 +---- lib/view_component/template.rb | 142 +++++++++++------- .../variants_component.html+mini.watch.erb | 1 - test/sandbox/test/rendering_test.rb | 8 - 7 files changed, 103 insertions(+), 88 deletions(-) delete mode 100644 test/sandbox/app/components/variants_component.html+mini.watch.erb diff --git a/.gitignore b/.gitignore index 340f093f5..dfe6728fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.gem *.rbc .ruby-version +.DS_Store /.config /coverage/assets /coverage/index.html diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 868adf283..9287c2522 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -24,6 +24,10 @@ nav_order: 5 *Joel Hawksley* +* BREAKING: Remove support for variant names containing `.` to be consistent with Rails. + + *Stephen Nelson* + * Ensure HTML output safety wrapper is used for all inline templates. *Joel Hawksley* diff --git a/docs/index.md b/docs/index.md index c810d6471..659efa588 100644 --- a/docs/index.md +++ b/docs/index.md @@ -193,6 +193,7 @@ ViewComponent is built by over a hundred members of the community, including: sammyhenningsson sampart seanpdoyle +sfnelson simonrand skryukov smashwilson diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index c4ab88df4..8e80ae026 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -169,27 +169,12 @@ def template_errors def gather_templates @templates ||= begin + path_parser = ActionView::Resolver::PathParser.new templates = @component.sidecar_files( ActionView::Template.template_handler_extensions ).map do |path| - # Extract format and variant from template filename - this_format, variant = - File - .basename(path) # "variants_component.html+mini.watch.erb" - .split(".")[1..-2] # ["html+mini", "watch"] - .join(".") # "html+mini.watch" - .split("+") # ["html", "mini.watch"] - .map(&:to_sym) # [:html, :"mini.watch"] - - out = Template.new( - component: @component, - type: :file, - path: path, - lineno: 0, - extension: path.split(".").last, - this_format: this_format.to_s.split(".").last&.to_sym, # strip locale from this_format, see #2113 - variant: variant - ) + details = path_parser.parse(path).details + out = Template::File.new(component: @component, path: path, details: details) out end @@ -201,24 +186,17 @@ def gather_templates ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) } .uniq .each do |method_name| - templates << Template.new( + templates << Template::InlineCall.new( component: @component, - type: :inline_call, - this_format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT, - variant: method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil, method_name: method_name, defined_on_self: component_instance_methods_on_self.include?(method_name) ) end if @component.inline_template.present? - templates << Template.new( + templates << Template::Inline.new( component: @component, - type: :inline, - path: @component.inline_template.path, - lineno: @component.inline_template.lineno, - source: @component.inline_template.source.dup, - extension: @component.inline_template.language + inline_template: @component.inline_template ) end diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index f01fd9368..09d7bdc5f 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -5,56 +5,112 @@ class Template DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true) DataNoSource = Struct.new(:source, :identifier, :type, keyword_init: true) - attr_reader :variant, :this_format, :type + attr_reader :details + + delegate :format, :variant, to: :details def initialize( component:, - type:, - this_format: nil, - variant: nil, + details:, lineno: nil, path: nil, - extension: nil, - source: nil, - method_name: nil, - defined_on_self: true + method_name: nil ) @component = component - @type = type - @this_format = this_format - @variant = variant&.to_sym + @details = details @lineno = lineno @path = path - @extension = extension - @source = source @method_name = method_name - @defined_on_self = defined_on_self - - @source_originally_nil = @source.nil? @call_method_name = if @method_name @method_name else out = +"call" - out << "_#{normalized_variant_name}" if @variant.present? - out << "_#{@this_format}" if @this_format.present? && @this_format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT + out << "_#{normalized_variant_name}" if variant.present? + out << "_#{format}" if format.present? && format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT out end end + class File < Template + def initialize(component:, details:, path:) + super( + component: component, + details: details, + path: path, + lineno: 0 + ) + end + + def type + :file + end + + # Load file each time we look up #source in case the file has been modified + def source + ::File.read(@path) + end + end + + class Inline < Template + attr_reader :source + + def initialize(component:, inline_template:) + details = ActionView::TemplateDetails.new(nil, inline_template.language.to_sym, nil, nil) + + super( + component: component, + details: details, + path: inline_template.path, + lineno: inline_template.lineno, + ) + + @source = inline_template.source.dup + end + + def type + :inline + end + end + + class InlineCall < Template + def initialize(component:, method_name:, defined_on_self:) + variant = method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil + details = ActionView::TemplateDetails.new(nil, nil, nil, variant) + + super( + component: component, + details: details, + method_name: method_name + ) + + @defined_on_self = defined_on_self + end + + def type + :inline_call + end + + def compile_to_component + @component.define_method(safe_method_name, @component.instance_method(@call_method_name)) + end + + def defined_on_self? + @defined_on_self + end + end + def compile_to_component - if !inline_call? - @component.silence_redefinition_of_method(@call_method_name) + @component.silence_redefinition_of_method(@call_method_name) - # rubocop:disable Style/EvalWithLocation - @component.class_eval <<-RUBY, @path, @lineno - def #{@call_method_name} - #{compiled_source} - end - RUBY - # rubocop:enable Style/EvalWithLocation + # rubocop:disable Style/EvalWithLocation + @component.class_eval <<-RUBY, @path, @lineno + def #{@call_method_name} + #{compiled_source} end + RUBY + # rubocop:enable Style/EvalWithLocation @component.define_method(safe_method_name, @component.instance_method(@call_method_name)) end @@ -72,19 +128,15 @@ def requires_compiled_superclass? end def inline_call? - @type == :inline_call + type == :inline_call end def inline? - @type == :inline + type == :inline end def default_format? - @this_format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT - end - - def format - @this_format + format.nil? || format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT end def safe_method_name @@ -92,35 +144,23 @@ def safe_method_name end def normalized_variant_name - @variant.to_s.gsub("-", "__").gsub(".", "___") - end - - def defined_on_self? - @defined_on_self + variant.to_s.gsub("-", "__") end private - def source - if @source_originally_nil - # Load file each time we look up #source in case the file has been modified - File.read(@path) - else - @source - end - end - def compiled_source - handler = ActionView::Template.handler_for_extension(@extension) + handler = details.handler_class this_source = source this_source.rstrip! if @component.strip_trailing_whitespace? short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path - type = ActionView::Template::Types[@this_format] + format = self.format || ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT + type = ActionView::Template::Types[format] if handler.method(:call).parameters.length > 1 handler.call( - DataWithSource.new(format: @this_format, identifier: @path, short_identifier: short_identifier, type: type), + DataWithSource.new(format: format, identifier: @path, short_identifier: short_identifier, type: type), this_source ) # :nocov: diff --git a/test/sandbox/app/components/variants_component.html+mini.watch.erb b/test/sandbox/app/components/variants_component.html+mini.watch.erb deleted file mode 100644 index b44d8505e..000000000 --- a/test/sandbox/app/components/variants_component.html+mini.watch.erb +++ /dev/null @@ -1 +0,0 @@ -Mini Watch with dot diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index ba0c8f797..0434c3eef 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -198,14 +198,6 @@ def test_renders_component_with_variant_containing_a_dash end end - def test_renders_component_with_variant_containing_a_dot - with_variant :"mini.watch" do - render_inline(VariantsComponent.new) - - assert_text("Mini Watch with dot") - end - end - def test_renders_default_template_when_variant_template_is_not_present with_variant :variant_without_template do render_inline(VariantsComponent.new) From ad6a494e4c42d55560776e5860ffcb19f6792984 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Fri, 17 Jan 2025 02:59:17 +1030 Subject: [PATCH 009/158] Refactor compiler and template to use requested details (#2158) --- docs/CHANGELOG.md | 11 +++ lib/view_component/base.rb | 25 +++--- lib/view_component/compiler.rb | 86 +++++++++---------- lib/view_component/errors.rb | 16 ++++ lib/view_component/request_details.rb | 30 +++++++ lib/view_component/template.rb | 71 +++++++-------- lib/view_component/test_helpers.rb | 20 +++-- ...bo_stream_format_component.html+custom.erb | 1 + .../turbo_stream_format_component.html.erb | 1 + .../turbo_stream_format_component.rb | 4 + test/sandbox/test/rendering_test.rb | 34 +++++++- 11 files changed, 194 insertions(+), 105 deletions(-) create mode 100644 lib/view_component/request_details.rb create mode 100644 test/sandbox/app/components/turbo_stream_format_component.html+custom.erb create mode 100644 test/sandbox/app/components/turbo_stream_format_component.html.erb create mode 100644 test/sandbox/app/components/turbo_stream_format_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9287c2522..df46266a8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -28,6 +28,17 @@ nav_order: 5 *Stephen Nelson* +* BREAKING: Use ActionView's `lookup_context` for picking templates instead of the request format. + + 3.15 added support for using templates that match the request format, i.e. if `/resource.csv` is requested then + ViewComponents would pick `_component.csv.erb` over `_component.html.erb`. + + With this release, the request format is no longer considered and instead ViewComponent will use the Rails logic + for picking the most appropriate template type, i.e. the csv template will be used if it matches the `Accept` header + or because the controller uses a `respond_to` block to pick the response format. + + *Stephen Nelson* + * Ensure HTML output safety wrapper is used for all inline templates. *Joel Hawksley* diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 69d8815d6..5099901a9 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -9,6 +9,7 @@ require "view_component/errors" require "view_component/inline_template" require "view_component/preview" +require "view_component/request_details" require "view_component/slotable" require "view_component/slotable_default" require "view_component/template" @@ -63,6 +64,8 @@ def set_original_view_context(view_context) self.__vc_original_view_context = view_context end + using RequestDetails + # Entrypoint for rendering components. # # - `view_context`: ActionView context from calling view @@ -90,13 +93,12 @@ def render_in(view_context, &block) # For i18n @virtual_path ||= virtual_path - # For template variants (+phone, +desktop, etc.) - @__vc_variant ||= @lookup_context.variants.first + # Describes the inferred request constraints (locales, formats, variants) + @__vc_requested_details ||= @lookup_context.vc_requested_details # For caching, such as #cache_if @current_template = nil unless defined?(@current_template) old_current_template = @current_template - @current_template = self if block && defined?(@__vc_content_set_by_with_content) raise DuplicateContentError.new(self.class.name) @@ -108,7 +110,7 @@ def render_in(view_context, &block) before_render if render? - rendered_template = render_template_for(@__vc_variant, __vc_request&.format&.to_sym).to_s + rendered_template = render_template_for(@__vc_requested_details).to_s # Avoid allocating new string when output_preamble and output_postamble are blank if output_preamble.blank? && output_postamble.blank? @@ -156,7 +158,7 @@ def render_parent_to_string target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] @__vc_parent_render_level += 1 - target_render.bind_call(self, @__vc_variant) + target_render.bind_call(self, @__vc_requested_details) ensure @__vc_parent_render_level -= 1 end @@ -267,11 +269,10 @@ def view_cache_dependencies [] end - # For caching, such as #cache_if - # - # @private + # Rails expects us to define `format` on all renderables, + # but we do not know the `format` of a ViewComponent until runtime. def format - @__vc_variant if defined?(@__vc_variant) + nil end # The current request. Use sparingly as doing so introduces coupling that @@ -328,7 +329,7 @@ def content_evaluated? end def maybe_escape_html(text) - return text if __vc_request && !__vc_request.format.html? + return text if @current_template && !@current_template.html? return text if text.blank? if text.html_safe? @@ -517,12 +518,12 @@ def inherited(child) # meaning it will not be called for any children and thus not compile their templates. if !child.instance_methods(false).include?(:render_template_for) && !child.compiled? child.class_eval <<~RUBY, __FILE__, __LINE__ + 1 - def render_template_for(variant = nil, format = nil) + def render_template_for(requested_details) # Force compilation here so the compiler always redefines render_template_for. # This is mostly a safeguard to prevent infinite recursion. self.class.compile(raise_errors: true, force: true) # .compile replaces this method; call the new one - render_template_for(variant, format) + render_template_for(requested_details) end RUBY end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 8e80ae026..9bad61349 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -55,6 +55,23 @@ def compile(raise_errors: false, force: false) end end + # @return all matching compiled templates, in priority order based on the requested details from LookupContext + # + # @param [ActionView::TemplateDetails::Requested] requested_details i.e. locales, formats, variants + def find_templates_for(requested_details) + filtered_templates = @templates.select do |template| + template.details.matches?(requested_details) + end + + if filtered_templates.count > 1 + filtered_templates.sort_by! do |template| + template.details.sort_key_for(requested_details) + end + end + + filtered_templates + end + private attr_reader :templates @@ -64,40 +81,25 @@ def define_render_template_for template.compile_to_component end - method_body = - if @templates.one? - @templates.first.safe_method_name_call - elsif (template = @templates.find(&:inline?)) - template.safe_method_name_call - else - branches = [] - - @templates.each do |template| - conditional = - if template.inline_call? - "variant&.to_sym == #{template.variant.inspect}" - else - [ - template.default_format? ? "(format == #{ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT.inspect} || format.nil?)" : "format == #{template.format.inspect}", - template.variant.nil? ? "variant.nil?" : "variant&.to_sym == #{template.variant.inspect}" - ].join(" && ") - end - - branches << [conditional, template.safe_method_name_call] - end + @component.silence_redefinition_of_method(:render_template_for) - out = branches.each_with_object(+"") do |(conditional, branch_body), memo| - memo << "#{(!memo.present?) ? "if" : "elsif"} #{conditional}\n #{branch_body}\n" + if @templates.one? + template = @templates.first + safe_call = template.safe_method_name_call + @component.define_method(:render_template_for) do |_| + @current_template = template + instance_exec(&safe_call) + end + else + compiler = self + @component.define_method(:render_template_for) do |details| + if (@current_template = compiler.find_templates_for(details).first) + instance_exec(&@current_template.safe_method_name_call) + else + raise MissingTemplateError.new(self.class.name, details) end - out << "else\n #{templates.find { _1.variant.nil? && _1.default_format? }.safe_method_name_call}\nend" end - - @component.silence_redefinition_of_method(:render_template_for) - @component.class_eval <<-RUBY, __FILE__, __LINE__ + 1 - def render_template_for(variant = nil, format = nil) - #{method_body} end - RUBY end def template_errors @@ -168,15 +170,18 @@ def template_errors def gather_templates @templates ||= - begin + if @component.inline_template.present? + [Template::Inline.new( + component: @component, + inline_template: @component.inline_template + )] + else path_parser = ActionView::Resolver::PathParser.new templates = @component.sidecar_files( ActionView::Template.template_handler_extensions ).map do |path| details = path_parser.parse(path).details - out = Template::File.new(component: @component, path: path, details: details) - - out + Template::File.new(component: @component, path: path, details: details) end component_instance_methods_on_self = @component.instance_methods(false) @@ -186,17 +191,10 @@ def gather_templates ).flat_map { |ancestor| ancestor.instance_methods(false).grep(/^call(_|$)/) } .uniq .each do |method_name| - templates << Template::InlineCall.new( - component: @component, - method_name: method_name, - defined_on_self: component_instance_methods_on_self.include?(method_name) - ) - end - - if @component.inline_template.present? - templates << Template::Inline.new( + templates << Template::InlineCall.new( component: @component, - inline_template: @component.inline_template + method_name: method_name, + defined_on_self: component_instance_methods_on_self.include?(method_name) ) end diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index e8cb8b10e..22f445569 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -38,6 +38,22 @@ def initialize(example) end end + class MissingTemplateError < StandardError + MESSAGE = + "No templates for COMPONENT match the request DETAIL.\n\n" \ + "To fix this issue, provide a suitable template." + + def initialize(component, request_detail) + detail = { + locale: request_detail.locale, + formats: request_detail.formats, + variants: request_detail.variants, + handlers: request_detail.handlers + } + super(MESSAGE.gsub("COMPONENT", component).gsub("DETAIL", detail.inspect)) + end + end + class DuplicateContentError < StandardError MESSAGE = "It looks like a block was provided after calling `with_content` on COMPONENT, " \ diff --git a/lib/view_component/request_details.rb b/lib/view_component/request_details.rb new file mode 100644 index 000000000..c502ce1eb --- /dev/null +++ b/lib/view_component/request_details.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ViewComponent + # LookupContext computes and encapsulates @details for each request + # so that it doesn't need to be recomputed on each partial render. + # This data is wrapped in ActionView::TemplateDetails::Requested and + # used by instances of ActionView::Resolver to choose which template + # best matches the request. + # + # ActionView considers this logic internal to template/partial resolution. + # We're exposing it to the compiler via `refine` so that ViewComponent + # can match Rails' template picking logic. + module RequestDetails + refine ActionView::LookupContext do + # Return an abstraction for matching and sorting available templates + # based on the current lookup context details. + # + # @return ActionView::TemplateDetails::Requested + # @see ActionView::LookupContext#detail_args_for + # @see ActionView::FileSystemResolver#_find_all + def vc_requested_details(user_details = {}) + # The hash `user_details` would normally be the standard arguments that + # `render` accepts, but there's currently no mechanism for users to + # provide these when calling render on a ViewComponent. + details, cached = detail_args_for(user_details) + cached || ActionView::TemplateDetails::Requested.new(**details) + end + end + end +end diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index 09d7bdc5f..969a2e8ee 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -7,30 +7,14 @@ class Template attr_reader :details - delegate :format, :variant, to: :details - - def initialize( - component:, - details:, - lineno: nil, - path: nil, - method_name: nil - ) + delegate :virtual_path, to: :@component + delegate :format, :variant, to: :@details + + def initialize(component:, details:, lineno: nil, path: nil) @component = component @details = details @lineno = lineno @path = path - @method_name = method_name - - @call_method_name = - if @method_name - @method_name - else - out = +"call" - out << "_#{normalized_variant_name}" if variant.present? - out << "_#{format}" if format.present? && format != ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT - out - end end class File < Template @@ -79,12 +63,9 @@ def initialize(component:, method_name:, defined_on_self:) variant = method_name.to_s.include?("call_") ? method_name.to_s.sub("call_", "").to_sym : nil details = ActionView::TemplateDetails.new(nil, nil, nil, variant) - super( - component: component, - details: details, - method_name: method_name - ) + super(component: component, details: details) + @call_method_name = method_name @defined_on_self = defined_on_self end @@ -96,19 +77,29 @@ def compile_to_component @component.define_method(safe_method_name, @component.instance_method(@call_method_name)) end + def safe_method_name_call + m = safe_method_name + proc do + maybe_escape_html(send(m)) do + Kernel.warn("WARNING: The #{self.class} component rendered HTML-unsafe output. " \ + "The output will be automatically escaped, but you may want to investigate.") + end + end + end + def defined_on_self? @defined_on_self end end def compile_to_component - @component.silence_redefinition_of_method(@call_method_name) + @component.silence_redefinition_of_method(call_method_name) # rubocop:disable Style/EvalWithLocation - @component.class_eval <<-RUBY, @path, @lineno - def #{@call_method_name} - #{compiled_source} - end + @component.class_eval <<~RUBY, @path, @lineno + def #{call_method_name} + #{compiled_source} + end RUBY # rubocop:enable Style/EvalWithLocation @@ -116,11 +107,8 @@ def #{@call_method_name} end def safe_method_name_call - return safe_method_name unless inline_call? - - "maybe_escape_html(#{safe_method_name}) " \ - "{ Kernel.warn('WARNING: The #{@component} component rendered HTML-unsafe output. " \ - "The output will be automatically escaped, but you may want to investigate.') } " + m = safe_method_name + proc { send(m) } end def requires_compiled_superclass? @@ -131,16 +119,19 @@ def inline_call? type == :inline_call end - def inline? - type == :inline - end - def default_format? format.nil? || format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT end + alias_method :html?, :default_format? + + def call_method_name + @call_method_name ||= + ["call", (normalized_variant_name if variant.present?), (format unless default_format?)] + .compact.join("_").to_sym + end def safe_method_name - "_#{@call_method_name}_#{@component.name.underscore.gsub("/", "__")}" + "_#{call_method_name}_#{@component.name.underscore.gsub("/", "__")}" end def normalized_variant_name diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 58e4e2a4c..6b6ace7b2 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -127,11 +127,11 @@ def render_in_view_context(*args, &block) # end # ``` # - # @param variant [Symbol] The variant to be set for the provided block. - def with_variant(variant) + # @param variants [Symbol[]] The variants to be set for the provided block. + def with_variant(*variants) old_variants = vc_test_controller.view_context.lookup_context.variants - vc_test_controller.view_context.lookup_context.variants = variant + vc_test_controller.view_context.lookup_context.variants += variants yield ensure vc_test_controller.view_context.lookup_context.variants = old_variants @@ -164,9 +164,14 @@ def with_controller_class(klass) # end # ``` # - # @param format [Symbol] The format to be set for the provided block. - def with_format(format) - with_request_url("/", format: format) { yield } + # @param formats [Symbol[]] The format(s) to be set for the provided block. + def with_format(*formats) + old_formats = vc_test_controller.view_context.lookup_context.formats + + vc_test_controller.view_context.lookup_context.formats = formats + yield + ensure + vc_test_controller.view_context.lookup_context.formats = old_formats end # Set the URL of the current request (such as when using request-dependent path helpers): @@ -196,7 +201,7 @@ def with_format(format) # @param full_path [String] The path to set for the current request. # @param host [String] The host to set for the current request. # @param method [String] The request method to set for the current request. - def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT) + def with_request_url(full_path, host: nil, method: nil) old_request_host = vc_test_request.host old_request_method = vc_test_request.request_method old_request_path_info = vc_test_request.path_info @@ -216,7 +221,6 @@ def with_request_url(full_path, host: nil, method: nil, format: ViewComponent::B vc_test_request.set_header("action_dispatch.request.query_parameters", Rack::Utils.parse_nested_query(query).with_indifferent_access) vc_test_request.set_header(Rack::QUERY_STRING, query) - vc_test_request.format = format yield ensure vc_test_request.host = old_request_host diff --git a/test/sandbox/app/components/turbo_stream_format_component.html+custom.erb b/test/sandbox/app/components/turbo_stream_format_component.html+custom.erb new file mode 100644 index 000000000..2a019d410 --- /dev/null +++ b/test/sandbox/app/components/turbo_stream_format_component.html+custom.erb @@ -0,0 +1 @@ +Hi turbo stream custom! diff --git a/test/sandbox/app/components/turbo_stream_format_component.html.erb b/test/sandbox/app/components/turbo_stream_format_component.html.erb new file mode 100644 index 000000000..f6e0d0b4f --- /dev/null +++ b/test/sandbox/app/components/turbo_stream_format_component.html.erb @@ -0,0 +1 @@ +Hi turbo stream! diff --git a/test/sandbox/app/components/turbo_stream_format_component.rb b/test/sandbox/app/components/turbo_stream_format_component.rb new file mode 100644 index 000000000..ea4a3d0fc --- /dev/null +++ b/test/sandbox/app/components/turbo_stream_format_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class TurboStreamFormatComponent < ViewComponent::Base +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 0434c3eef..bdd120f0c 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -15,7 +15,7 @@ def test_render_inline_allocations ViewComponent::CompileCache.cache.delete(MyComponent) MyComponent.ensure_compiled - assert_allocations("3.4.0" => 109, "3.3.6" => 115, "3.3.0" => 124, "3.2.6" => 114) do + assert_allocations("3.5.0" => 104, "3.4.1" => 107, "3.3.6" => 107, "3.2.6" => 105) do render_inline(MyComponent.new) end @@ -190,6 +190,14 @@ def test_renders_component_with_variant end end + def test_renders_component_with_multiple_variants + with_variant :app, :phone do + render_inline(VariantsComponent.new) + + assert_text("Phone") + end + end + def test_renders_component_with_variant_containing_a_dash with_variant :"mini-watch" do render_inline(VariantsComponent.new) @@ -1198,6 +1206,20 @@ def test_with_format end end + def test_with_format_missing + with_format(:xml) do + exception = + assert_raises ViewComponent::MissingTemplateError do + render_inline(MultipleFormatsComponent.new) + end + + assert_includes( + exception.message, + "No templates for MultipleFormatsComponent match the request" + ) + end + end + def test_localised_component render_inline(LocalisedComponent.new) @@ -1209,4 +1231,14 @@ def test_request_param assert_text("foo") end + + def test_turbo_stream_format_custom_variant + with_format(:turbo_stream, :html) do + with_variant(:custom) do + render_inline(TurboStreamFormatComponent.new) + + assert_text("Hi turbo stream custom!") + end + end + end end From de6d968175b6a37eedccd8506756dbc69e753b82 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 4 Feb 2025 16:03:04 -0700 Subject: [PATCH 010/158] fix allocations check --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 0186f3b9c..18aa0bb4e 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 117, "3.4.1" => 117, "3.3.7" => 129} : + {"3.5.0" => 117, "3.4.1" => 104, "3.3.7" => 129} : {"3.3.7" => 120, "3.3.0" => 120, "3.2.7" => 118, "3.1.6" => 118, "3.0.7" => 127} assert_allocations(**allocations) do From a17e54058a8237675e90d41df44ecfa47f85009a Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 4 Feb 2025 16:22:55 -0700 Subject: [PATCH 011/158] update allocation counts (#2204) --- test/sandbox/test/rendering_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 18aa0bb4e..d19863f40 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,8 +16,8 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 117, "3.4.1" => 104, "3.3.7" => 129} : - {"3.3.7" => 120, "3.3.0" => 120, "3.2.7" => 118, "3.1.6" => 118, "3.0.7" => 127} + {"3.5.0" => 104, "3.4.1" => 104, "3.3.7" => 108} : + {"3.3.7" => 107, "3.3.0" => 120, "3.2.7" => 105, "3.1.6" => 118, "3.0.7" => 127} assert_allocations(**allocations) do render_inline(MyComponent.new) From 84f310d4933097df90a1059660dc6249b9b2bd65 Mon Sep 17 00:00:00 2001 From: Paul Sadauskas Date: Mon, 10 Mar 2025 12:46:00 -0700 Subject: [PATCH 012/158] Move generators into ViewComponent namespace (#2130) * Move generators into ViewComponent namespace * Update tests * Update changelog * Update docs with new generator namespace --- docs/CHANGELOG.md | 9 +++++++++ docs/guide/generators.md | 20 +++++++++---------- docs/guide/getting-started.md | 2 +- docs/guide/templates.md | 2 +- .../view_component}/abstract_generator.rb | 0 .../view_component}/component/USAGE | 0 .../component/component_generator.rb | 4 ++-- .../component/templates/component.rb.tt | 0 .../view_component/erb/erb_generator.rb} | 6 +++--- .../erb/templates/component.html.erb.tt | 0 .../view_component/haml/haml_generator.rb} | 6 +++--- .../haml/templates/component.html.haml.tt | 0 .../locale/locale_generator.rb} | 6 +++--- .../preview/preview_generator.rb} | 4 ++-- .../preview/templates/component_preview.rb.tt | 0 .../view_component/rspec/rspec_generator.rb} | 6 +++--- .../rspec/templates/component_spec.rb.tt | 0 .../view_component/slim/slim_generator.rb} | 6 +++--- .../slim/templates/component.html.slim.tt | 0 .../stimulus/stimulus_generator.rb} | 6 +++--- .../templates/component_controller.js.tt | 0 .../templates/component_controller.ts.tt | 0 .../tailwindcss/tailwindcss_generator.rb | 11 ++++++++++ .../templates/component.html.erb.tt | 0 .../test_unit/templates/component_test.rb.tt | 0 .../test_unit/test_unit_generator.rb} | 4 ++-- .../tailwindcss/component_generator.rb | 11 ---------- .../generators/component_generator_test.rb | 4 ++-- .../test/generators/erb_generator_test.rb | 4 ++-- .../test/generators/haml_generator_test.rb | 4 ++-- .../test/generators/locale_generator_test.rb | 4 ++-- .../test/generators/preview_generator_test.rb | 4 ++-- .../test/generators/rspec_generator_test.rb | 4 ++-- .../test/generators/slim_generator_test.rb | 4 ++-- .../generators/stimulus_generator_test.rb | 4 ++-- .../generators/tailwindcss_generator_test.rb | 5 ++--- .../generators/test_unit_generator_test.rb | 4 ++-- .../generators/component_generator_test.rb | 4 ++-- .../test/generators/preview_generator_test.rb | 4 ++-- .../generators/test_unit_generator_test.rb | 4 ++-- 40 files changed, 82 insertions(+), 74 deletions(-) rename lib/{rails/generators => generators/view_component}/abstract_generator.rb (100%) rename lib/{rails/generators => generators/view_component}/component/USAGE (100%) rename lib/{rails/generators => generators/view_component}/component/component_generator.rb (96%) rename lib/{rails/generators => generators/view_component}/component/templates/component.rb.tt (100%) rename lib/{rails/generators/erb/component_generator.rb => generators/view_component/erb/erb_generator.rb} (82%) rename lib/{rails/generators => generators/view_component}/erb/templates/component.html.erb.tt (100%) rename lib/{rails/generators/haml/component_generator.rb => generators/view_component/haml/haml_generator.rb} (70%) rename lib/{rails/generators => generators/view_component}/haml/templates/component.html.haml.tt (100%) rename lib/{rails/generators/locale/component_generator.rb => generators/view_component/locale/locale_generator.rb} (90%) rename lib/{rails/generators/preview/component_generator.rb => generators/view_component/preview/preview_generator.rb} (93%) rename lib/{rails/generators => generators/view_component}/preview/templates/component_preview.rb.tt (100%) rename lib/{rails/generators/rspec/component_generator.rb => generators/view_component/rspec/rspec_generator.rb} (85%) rename lib/{rails/generators => generators/view_component}/rspec/templates/component_spec.rb.tt (100%) rename lib/{rails/generators/slim/component_generator.rb => generators/view_component/slim/slim_generator.rb} (70%) rename lib/{rails/generators => generators/view_component}/slim/templates/component.html.slim.tt (100%) rename lib/{rails/generators/stimulus/component_generator.rb => generators/view_component/stimulus/stimulus_generator.rb} (89%) rename lib/{rails/generators => generators/view_component}/stimulus/templates/component_controller.js.tt (100%) rename lib/{rails/generators => generators/view_component}/stimulus/templates/component_controller.ts.tt (100%) create mode 100644 lib/generators/view_component/tailwindcss/tailwindcss_generator.rb rename lib/{rails/generators => generators/view_component}/tailwindcss/templates/component.html.erb.tt (100%) rename lib/{rails/generators => generators/view_component}/test_unit/templates/component_test.rb.tt (100%) rename lib/{rails/generators/test_unit/component_generator.rb => generators/view_component/test_unit/test_unit_generator.rb} (83%) delete mode 100644 lib/rails/generators/tailwindcss/component_generator.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d73250701..422935d19 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,15 @@ nav_order: 5 ## 4.0.0 +* BREAKING: Move generators to a ViewComponent namespace. + + Before, ViewComponent generators pollute the generator namespace with a bunch of top level items, and claim the generic "component" name. + + Now, generators live in a "view_component" module/namespace, so what was before `rails g + component` is now `rails g view_component:component`. + + *Paul Sadauskas* + * BREAKING: Require [non-EOL](https://endoflife.date/rails) Rails (`>= 7.1.0`). *Joel Hawksley* diff --git a/docs/guide/generators.md b/docs/guide/generators.md index 6a81d7095..1a1ed80b1 100644 --- a/docs/guide/generators.md +++ b/docs/guide/generators.md @@ -11,7 +11,7 @@ The generator accepts a component name and a list of arguments. To create an `ExampleComponent` with `title` and `content` attributes: ```console -bin/rails generate component Example title content +bin/rails generate view_component:component Example title content create app/components/example_component.rb invoke test_unit @@ -25,7 +25,7 @@ bin/rails generate component Example title content To generate a namespaced `Sections::ExampleComponent`: ```console -bin/rails generate component Sections::Example title content +bin/rails generate view_component:component Sections::Example title content create app/components/sections/example_component.rb invoke test_unit @@ -51,7 +51,7 @@ config.eager_load_paths << Rails.root.join("app/views/components") ViewComponent includes template generators for the `erb`, `haml`, and `slim` template engines and will default to the template engine specified in `config.generators.template_engine`. ```console -bin/rails generate component Example title --template-engine slim +bin/rails generate view_component:component Example title --template-engine slim create app/components/example_component.rb invoke test_unit @@ -65,7 +65,7 @@ bin/rails generate component Example title --template-engine slim By default, `config.generators.test_framework` is used. ```console -bin/rails generate component Example title --test-framework rspec +bin/rails generate view_component:component Example title --test-framework rspec create app/components/example_component.rb invoke rspec @@ -80,7 +80,7 @@ Since 2.25.0 {: .label } ```console -bin/rails generate component Example title --preview +bin/rails generate view_component:component Example title --preview create app/components/example_component.rb invoke test_unit @@ -97,7 +97,7 @@ Since 2.38.0 {: .label } ```console -bin/rails generate component Example title --stimulus +bin/rails generate view_component:component Example title --stimulus create app/components/example_component.rb invoke test_unit @@ -121,7 +121,7 @@ Since 2.47.0 {: .label } ```console -bin/rails generate component Example title --locale +bin/rails generate view_component:component Example title --locale create app/components/example_component.rb invoke test_unit @@ -142,7 +142,7 @@ Since 2.16.0 {: .label } ```console -bin/rails generate component Example title --sidecar +bin/rails generate view_component:component Example title --sidecar create app/components/example_component.rb invoke test_unit @@ -159,7 +159,7 @@ Since 2.24.0 {: .label } ```console -bin/rails generate component Example title --inline +bin/rails generate view_component:component Example title --inline create app/components/example_component.rb invoke test_unit @@ -175,7 +175,7 @@ Since 2.41.0 By default, `ApplicationComponent` is used if defined, `ViewComponent::Base` otherwise. ```console -bin/rails generate component Example title content --parent MyBaseComponent +bin/rails generate view_component:component Example title content --parent MyBaseComponent create app/components/example_component.rb invoke test_unit diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 21adfcaa8..1eb8d4df8 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -29,7 +29,7 @@ Use the component generator to create a new ViewComponent. The generator accepts a component name and a list of arguments: ```console -bin/rails generate component Example title +bin/rails generate view_component:component Example title invoke test_unit create test/components/example_component_test.rb diff --git a/docs/guide/templates.md b/docs/guide/templates.md index 2caf95ec0..988f04352 100644 --- a/docs/guide/templates.md +++ b/docs/guide/templates.md @@ -80,7 +80,7 @@ app/components To generate a component with a sidecar directory, use the `--sidecar` flag: ```console -bin/rails generate component Example title --sidecar +bin/rails generate view_component:component Example title --sidecar invoke test_unit create test/components/example_component_test.rb create app/components/example_component.rb diff --git a/lib/rails/generators/abstract_generator.rb b/lib/generators/view_component/abstract_generator.rb similarity index 100% rename from lib/rails/generators/abstract_generator.rb rename to lib/generators/view_component/abstract_generator.rb diff --git a/lib/rails/generators/component/USAGE b/lib/generators/view_component/component/USAGE similarity index 100% rename from lib/rails/generators/component/USAGE rename to lib/generators/view_component/component/USAGE diff --git a/lib/rails/generators/component/component_generator.rb b/lib/generators/view_component/component/component_generator.rb similarity index 96% rename from lib/rails/generators/component/component_generator.rb rename to lib/generators/view_component/component/component_generator.rb index d5e4b8b3c..dd2a37cca 100644 --- a/lib/rails/generators/component/component_generator.rb +++ b/lib/generators/view_component/component/component_generator.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require "rails/generators/abstract_generator" +require "generators/view_component/abstract_generator" -module Rails +module ViewComponent module Generators class ComponentGenerator < Rails::Generators::NamedBase include ViewComponent::AbstractGenerator diff --git a/lib/rails/generators/component/templates/component.rb.tt b/lib/generators/view_component/component/templates/component.rb.tt similarity index 100% rename from lib/rails/generators/component/templates/component.rb.tt rename to lib/generators/view_component/component/templates/component.rb.tt diff --git a/lib/rails/generators/erb/component_generator.rb b/lib/generators/view_component/erb/erb_generator.rb similarity index 82% rename from lib/rails/generators/erb/component_generator.rb rename to lib/generators/view_component/erb/erb_generator.rb index e2e2bf55b..8b5ea988d 100644 --- a/lib/rails/generators/erb/component_generator.rb +++ b/lib/generators/view_component/erb/erb_generator.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true require "rails/generators/erb" -require "rails/generators/abstract_generator" +require "generators/view_component/abstract_generator" -module Erb +module ViewComponent module Generators - class ComponentGenerator < Base + class ErbGenerator < Rails::Generators::NamedBase include ViewComponent::AbstractGenerator source_root File.expand_path("templates", __dir__) diff --git a/lib/rails/generators/erb/templates/component.html.erb.tt b/lib/generators/view_component/erb/templates/component.html.erb.tt similarity index 100% rename from lib/rails/generators/erb/templates/component.html.erb.tt rename to lib/generators/view_component/erb/templates/component.html.erb.tt diff --git a/lib/rails/generators/haml/component_generator.rb b/lib/generators/view_component/haml/haml_generator.rb similarity index 70% rename from lib/rails/generators/haml/component_generator.rb rename to lib/generators/view_component/haml/haml_generator.rb index c25d08dc1..0489b140d 100644 --- a/lib/rails/generators/haml/component_generator.rb +++ b/lib/generators/view_component/haml/haml_generator.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "rails/generators/erb/component_generator" +require "generators/view_component/erb/erb_generator" -module Haml +module ViewComponent module Generators - class ComponentGenerator < Erb::Generators::ComponentGenerator + class HamlGenerator < ViewComponent::Generators::ErbGenerator include ViewComponent::AbstractGenerator source_root File.expand_path("templates", __dir__) diff --git a/lib/rails/generators/haml/templates/component.html.haml.tt b/lib/generators/view_component/haml/templates/component.html.haml.tt similarity index 100% rename from lib/rails/generators/haml/templates/component.html.haml.tt rename to lib/generators/view_component/haml/templates/component.html.haml.tt diff --git a/lib/rails/generators/locale/component_generator.rb b/lib/generators/view_component/locale/locale_generator.rb similarity index 90% rename from lib/rails/generators/locale/component_generator.rb rename to lib/generators/view_component/locale/locale_generator.rb index 55f735353..2bb61a708 100644 --- a/lib/rails/generators/locale/component_generator.rb +++ b/lib/generators/view_component/locale/locale_generator.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "rails/generators/abstract_generator" +require "generators/view_component/abstract_generator" -module Locale +module ViewComponent module Generators - class ComponentGenerator < ::Rails::Generators::NamedBase + class LocaleGenerator < ::Rails::Generators::NamedBase include ViewComponent::AbstractGenerator source_root File.expand_path("templates", __dir__) diff --git a/lib/rails/generators/preview/component_generator.rb b/lib/generators/view_component/preview/preview_generator.rb similarity index 93% rename from lib/rails/generators/preview/component_generator.rb rename to lib/generators/view_component/preview/preview_generator.rb index abb6fbd07..dbaf3a261 100644 --- a/lib/rails/generators/preview/component_generator.rb +++ b/lib/generators/view_component/preview/preview_generator.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -module Preview +module ViewComponent module Generators - class ComponentGenerator < ::Rails::Generators::NamedBase + class PreviewGenerator < ::Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) class_option :preview_path, type: :string, desc: "Path for previews, required when multiple preview paths are configured", default: ViewComponent::Base.config.generate.preview_path diff --git a/lib/rails/generators/preview/templates/component_preview.rb.tt b/lib/generators/view_component/preview/templates/component_preview.rb.tt similarity index 100% rename from lib/rails/generators/preview/templates/component_preview.rb.tt rename to lib/generators/view_component/preview/templates/component_preview.rb.tt diff --git a/lib/rails/generators/rspec/component_generator.rb b/lib/generators/view_component/rspec/rspec_generator.rb similarity index 85% rename from lib/rails/generators/rspec/component_generator.rb rename to lib/generators/view_component/rspec/rspec_generator.rb index 605ada5fb..eff496455 100644 --- a/lib/rails/generators/rspec/component_generator.rb +++ b/lib/generators/view_component/rspec/rspec_generator.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "rails/generators/abstract_generator" +require "generators/view_component/abstract_generator" -module Rspec +module ViewComponent module Generators - class ComponentGenerator < ::Rails::Generators::NamedBase + class RspecGenerator < ::Rails::Generators::NamedBase include ViewComponent::AbstractGenerator source_root File.expand_path("templates", __dir__) diff --git a/lib/rails/generators/rspec/templates/component_spec.rb.tt b/lib/generators/view_component/rspec/templates/component_spec.rb.tt similarity index 100% rename from lib/rails/generators/rspec/templates/component_spec.rb.tt rename to lib/generators/view_component/rspec/templates/component_spec.rb.tt diff --git a/lib/rails/generators/slim/component_generator.rb b/lib/generators/view_component/slim/slim_generator.rb similarity index 70% rename from lib/rails/generators/slim/component_generator.rb rename to lib/generators/view_component/slim/slim_generator.rb index a8e0576bc..29a0bcdbe 100644 --- a/lib/rails/generators/slim/component_generator.rb +++ b/lib/generators/view_component/slim/slim_generator.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "rails/generators/erb/component_generator" +require "generators/view_component/erb/erb_generator" -module Slim +module ViewComponent module Generators - class ComponentGenerator < Erb::Generators::ComponentGenerator + class SlimGenerator < ViewComponent::Generators::ErbGenerator include ViewComponent::AbstractGenerator source_root File.expand_path("templates", __dir__) diff --git a/lib/rails/generators/slim/templates/component.html.slim.tt b/lib/generators/view_component/slim/templates/component.html.slim.tt similarity index 100% rename from lib/rails/generators/slim/templates/component.html.slim.tt rename to lib/generators/view_component/slim/templates/component.html.slim.tt diff --git a/lib/rails/generators/stimulus/component_generator.rb b/lib/generators/view_component/stimulus/stimulus_generator.rb similarity index 89% rename from lib/rails/generators/stimulus/component_generator.rb rename to lib/generators/view_component/stimulus/stimulus_generator.rb index f81912ddf..85395a011 100644 --- a/lib/rails/generators/stimulus/component_generator.rb +++ b/lib/generators/view_component/stimulus/stimulus_generator.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "rails/generators/abstract_generator" +require "generators/view_component/abstract_generator" -module Stimulus +module ViewComponent module Generators - class ComponentGenerator < ::Rails::Generators::NamedBase + class StimulusGenerator < ::Rails::Generators::NamedBase include ViewComponent::AbstractGenerator source_root File.expand_path("templates", __dir__) diff --git a/lib/rails/generators/stimulus/templates/component_controller.js.tt b/lib/generators/view_component/stimulus/templates/component_controller.js.tt similarity index 100% rename from lib/rails/generators/stimulus/templates/component_controller.js.tt rename to lib/generators/view_component/stimulus/templates/component_controller.js.tt diff --git a/lib/rails/generators/stimulus/templates/component_controller.ts.tt b/lib/generators/view_component/stimulus/templates/component_controller.ts.tt similarity index 100% rename from lib/rails/generators/stimulus/templates/component_controller.ts.tt rename to lib/generators/view_component/stimulus/templates/component_controller.ts.tt diff --git a/lib/generators/view_component/tailwindcss/tailwindcss_generator.rb b/lib/generators/view_component/tailwindcss/tailwindcss_generator.rb new file mode 100644 index 000000000..102df5acd --- /dev/null +++ b/lib/generators/view_component/tailwindcss/tailwindcss_generator.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "generators/view_component/erb/erb_generator" + +module ViewComponent + module Generators + class TailwindcssGenerator < ViewComponent::Generators::ErbGenerator + source_root File.expand_path("templates", __dir__) + end + end +end diff --git a/lib/rails/generators/tailwindcss/templates/component.html.erb.tt b/lib/generators/view_component/tailwindcss/templates/component.html.erb.tt similarity index 100% rename from lib/rails/generators/tailwindcss/templates/component.html.erb.tt rename to lib/generators/view_component/tailwindcss/templates/component.html.erb.tt diff --git a/lib/rails/generators/test_unit/templates/component_test.rb.tt b/lib/generators/view_component/test_unit/templates/component_test.rb.tt similarity index 100% rename from lib/rails/generators/test_unit/templates/component_test.rb.tt rename to lib/generators/view_component/test_unit/templates/component_test.rb.tt diff --git a/lib/rails/generators/test_unit/component_generator.rb b/lib/generators/view_component/test_unit/test_unit_generator.rb similarity index 83% rename from lib/rails/generators/test_unit/component_generator.rb rename to lib/generators/view_component/test_unit/test_unit_generator.rb index 2eb2cfe28..1bae1aab5 100644 --- a/lib/rails/generators/test_unit/component_generator.rb +++ b/lib/generators/view_component/test_unit/test_unit_generator.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -module TestUnit +module ViewComponent module Generators - class ComponentGenerator < ::Rails::Generators::NamedBase + class TestUnitGenerator < ::Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) check_class_collision suffix: "ComponentTest" diff --git a/lib/rails/generators/tailwindcss/component_generator.rb b/lib/rails/generators/tailwindcss/component_generator.rb deleted file mode 100644 index fe0046248..000000000 --- a/lib/rails/generators/tailwindcss/component_generator.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require "rails/generators/erb/component_generator" - -module Tailwindcss - module Generators - class ComponentGenerator < Erb::Generators::ComponentGenerator - source_root File.expand_path("templates", __dir__) - end - end -end diff --git a/test/sandbox/test/generators/component_generator_test.rb b/test/sandbox/test/generators/component_generator_test.rb index 4cbd27bec..1a6f9a144 100644 --- a/test/sandbox/test/generators/component_generator_test.rb +++ b/test/sandbox/test/generators/component_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/component/component_generator" +require "generators/view_component/component/component_generator" Rails.application.load_generators class ComponentGeneratorTest < Rails::Generators::TestCase - tests Rails::Generators::ComponentGenerator + tests ViewComponent::Generators::ComponentGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/erb_generator_test.rb b/test/sandbox/test/generators/erb_generator_test.rb index 0c7800ea3..ec368c024 100644 --- a/test/sandbox/test/generators/erb_generator_test.rb +++ b/test/sandbox/test/generators/erb_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/erb/component_generator" +require "generators/view_component/erb/erb_generator" Rails.application.load_generators class ErbGeneratorTest < Rails::Generators::TestCase - tests Erb::Generators::ComponentGenerator + tests ViewComponent::Generators::ErbGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/haml_generator_test.rb b/test/sandbox/test/generators/haml_generator_test.rb index 9d37dd239..1cb6b3901 100644 --- a/test/sandbox/test/generators/haml_generator_test.rb +++ b/test/sandbox/test/generators/haml_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/haml/component_generator" +require "generators/view_component/haml/haml_generator" Rails.application.load_generators class HamlGeneratorTest < Rails::Generators::TestCase - tests Haml::Generators::ComponentGenerator + tests ViewComponent::Generators::HamlGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/locale_generator_test.rb b/test/sandbox/test/generators/locale_generator_test.rb index aba1a0601..e26dcc547 100644 --- a/test/sandbox/test/generators/locale_generator_test.rb +++ b/test/sandbox/test/generators/locale_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/locale/component_generator" +require "generators/view_component/locale/locale_generator" Rails.application.load_generators class LocaleGeneratorTest < Rails::Generators::TestCase - tests Locale::Generators::ComponentGenerator + tests ViewComponent::Generators::LocaleGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/preview_generator_test.rb b/test/sandbox/test/generators/preview_generator_test.rb index 3930ef2b3..e4f9b1c77 100644 --- a/test/sandbox/test/generators/preview_generator_test.rb +++ b/test/sandbox/test/generators/preview_generator_test.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/preview/component_generator" +require "generators/view_component/preview/preview_generator" # See: https://github.com/rails/rails/pull/47752#issuecomment-1720256371 require "active_record" @@ -9,7 +9,7 @@ Rails.application.load_generators class PreviewGeneratorTest < Rails::Generators::TestCase - tests Preview::Generators::ComponentGenerator + tests ViewComponent::Generators::PreviewGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/rspec_generator_test.rb b/test/sandbox/test/generators/rspec_generator_test.rb index 0286ef1fd..a65048529 100644 --- a/test/sandbox/test/generators/rspec_generator_test.rb +++ b/test/sandbox/test/generators/rspec_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/rspec/component_generator" +require "generators/view_component/rspec/rspec_generator" Rails.application.load_generators class RSpecGeneratorTest < Rails::Generators::TestCase - tests Rspec::Generators::ComponentGenerator + tests ViewComponent::Generators::RspecGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/slim_generator_test.rb b/test/sandbox/test/generators/slim_generator_test.rb index 4d64b21f8..d03b85fad 100644 --- a/test/sandbox/test/generators/slim_generator_test.rb +++ b/test/sandbox/test/generators/slim_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/slim/component_generator" +require "generators/view_component/slim/slim_generator" Rails.application.load_generators class SlimGeneratorTest < Rails::Generators::TestCase - tests Slim::Generators::ComponentGenerator + tests ViewComponent::Generators::SlimGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/stimulus_generator_test.rb b/test/sandbox/test/generators/stimulus_generator_test.rb index c2b6c6af8..2d211b97d 100644 --- a/test/sandbox/test/generators/stimulus_generator_test.rb +++ b/test/sandbox/test/generators/stimulus_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/stimulus/component_generator" +require "generators/view_component/stimulus/stimulus_generator" Rails.application.load_generators class StimulusGeneratorTest < Rails::Generators::TestCase - tests Stimulus::Generators::ComponentGenerator + tests ViewComponent::Generators::StimulusGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/tailwindcss_generator_test.rb b/test/sandbox/test/generators/tailwindcss_generator_test.rb index 736e401ff..c2a2c5b08 100644 --- a/test/sandbox/test/generators/tailwindcss_generator_test.rb +++ b/test/sandbox/test/generators/tailwindcss_generator_test.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/test_case" -require "rails/generators/tailwindcss/component_generator" +require "generators/view_component/tailwindcss/tailwindcss_generator" Rails.application.load_generators class TailwindcssGeneratorTest < Rails::Generators::TestCase - tests Tailwindcss::Generators::ComponentGenerator + tests ViewComponent::Generators::TailwindcssGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/sandbox/test/generators/test_unit_generator_test.rb b/test/sandbox/test/generators/test_unit_generator_test.rb index 00a9dbdda..e46ac027f 100644 --- a/test/sandbox/test/generators/test_unit_generator_test.rb +++ b/test/sandbox/test/generators/test_unit_generator_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "rails/generators/test_unit/component_generator" +require "generators/view_component/test_unit/test_unit_generator" Rails.application.load_generators class TestUnitGeneratorTest < Rails::Generators::TestCase - tests TestUnit::Generators::ComponentGenerator + tests ViewComponent::Generators::TestUnitGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/test_engine/test/generators/component_generator_test.rb b/test/test_engine/test/generators/component_generator_test.rb index 177c62ea1..2484b797b 100644 --- a/test/test_engine/test/generators/component_generator_test.rb +++ b/test/test_engine/test/generators/component_generator_test.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true require_relative "../../test_helper" -require "rails/generators/component/component_generator" +require "generators/view_component/component/component_generator" class ComponentGeneratorTest < Rails::Generators::TestCase - tests Rails::Generators::ComponentGenerator + tests ViewComponent::Generators::ComponentGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/test_engine/test/generators/preview_generator_test.rb b/test/test_engine/test/generators/preview_generator_test.rb index 64d815baa..410aa6f9b 100644 --- a/test/test_engine/test/generators/preview_generator_test.rb +++ b/test/test_engine/test/generators/preview_generator_test.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true require_relative "../../test_helper" -require "rails/generators/preview/component_generator" +require "generators/view_component/preview/preview_generator" class PreviewGeneratorTest < Rails::Generators::TestCase - tests Preview::Generators::ComponentGenerator + tests ViewComponent::Generators::PreviewGenerator destination Dir.mktmpdir setup :prepare_destination diff --git a/test/test_engine/test/generators/test_unit_generator_test.rb b/test/test_engine/test/generators/test_unit_generator_test.rb index a602b2d9c..6860f2d8a 100644 --- a/test/test_engine/test/generators/test_unit_generator_test.rb +++ b/test/test_engine/test/generators/test_unit_generator_test.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true require_relative "../../test_helper" -require "rails/generators/test_unit/component_generator" +require "generators/view_component/test_unit/test_unit_generator" class TestUnitGeneratorTest < Rails::Generators::TestCase - tests TestUnit::Generators::ComponentGenerator + tests ViewComponent::Generators::TestUnitGenerator destination Dir.mktmpdir setup :prepare_destination From bab31a03814a9dd97228c00209733da59d12c1fa Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 10 Mar 2025 14:46:07 -0600 Subject: [PATCH 013/158] BREAKING: Use Nokogiri::HTML5 instead of Nokogiri::HTML4 (#2218) * BREAKING: Use Nokogiri::HTML5 instead of Nokogiri::HTML4 for test helpers Co-authored-by: Noah Silvera * update allocation counts * allocation counts --------- Co-authored-by: Noah Silvera --- docs/CHANGELOG.md | 4 ++++ lib/view_component/test_helpers.rb | 10 +++++----- test/sandbox/test/rendering_test.rb | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a6dab1805..73a403020 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ nav_order: 5 ## 4.0.0 +* BREAKING: Use `Nokogiri::HTML5` instead of `Nokogiri::HTML4` for test helpers. + + *Noah Silvera*, *Joel Hawksley* + * BREAKING: Move generators to a ViewComponent namespace. Before, ViewComponent generators pollute the generator namespace with a bunch of top level items, and claim the generic "component" name. diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 6b6ace7b2..31f74b469 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -46,12 +46,12 @@ def assert_component_rendered # ``` # # @param component [ViewComponent::Base, ViewComponent::Collection] The instance of the component to be rendered. - # @return [Nokogiri::HTML] + # @return [Nokogiri::HTML5] def render_inline(component, **args, &block) @page = nil @rendered_content = vc_test_controller.view_context.render(component, args, &block) - Nokogiri::HTML.fragment(@rendered_content) + Nokogiri::HTML5.fragment(@rendered_content) end # `JSON.parse`-d component output. @@ -82,7 +82,7 @@ def rendered_json # @param name [String] The name of the preview to be rendered. # @param from [ViewComponent::Preview] The class of the preview to be rendered. # @param params [Hash] Parameters to be passed to the preview. - # @return [Nokogiri::HTML] + # @return [Nokogiri::HTML5] def render_preview(name, from: __vc_test_helpers_preview_class, params: {}) previews_controller = __vc_test_helpers_build_controller(Rails.application.config.view_component.preview_controller.constantize) @@ -98,7 +98,7 @@ def render_preview(name, from: __vc_test_helpers_preview_class, params: {}) @rendered_content = result - Nokogiri::HTML.fragment(@rendered_content) + Nokogiri::HTML5.fragment(@rendered_content) end # Execute the given block in the view context (using `instance_exec`). @@ -115,7 +115,7 @@ def render_preview(name, from: __vc_test_helpers_preview_class, params: {}) def render_in_view_context(*args, &block) @page = nil @rendered_content = vc_test_controller.view_context.instance_exec(*args, &block) - Nokogiri::HTML.fragment(@rendered_content) + Nokogiri::HTML5.fragment(@rendered_content) end ruby2_keywords(:render_in_view_context) if respond_to?(:ruby2_keywords, true) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index f473f8670..0be789ea3 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,8 +16,8 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 104, "3.4.1" => 104, "3.3.7" => 108} : - {"3.3.7" => 107, "3.3.0" => 120, "3.2.7" => 105, "3.1.6" => 118, "3.0.7" => 127} + {"3.5.0" => 73, "3.4.2" => 75, "3.3.7" => 76} : + {"3.3.7" => 75, "3.2.7" => 74} assert_allocations(**allocations) do render_inline(MyComponent.new) From 13c5537ef869f5c4cfbf43ab85b550b143d7363b Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 10 Mar 2025 15:24:32 -0600 Subject: [PATCH 014/158] Remove respond_to? checks (#2219) * do not use respond_to in gemspec * do not use respond_to in PreviewActions * remove unnecessary respond_to check * remove unnecessary respond_to check in template annotations helper Co-authored-by: Tiago Menegaz --------- Co-authored-by: Tiago Menegaz --- .../concerns/view_component/preview_actions.rb | 2 +- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 4 ++-- test/test_helper.rb | 16 ++++++---------- view_component.gemspec | 13 +++---------- 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/app/controllers/concerns/view_component/preview_actions.rb b/app/controllers/concerns/view_component/preview_actions.rb index 1a4e7536e..db20a3a09 100644 --- a/app/controllers/concerns/view_component/preview_actions.rb +++ b/app/controllers/concerns/view_component/preview_actions.rb @@ -10,7 +10,7 @@ module PreviewActions around_action :set_locale, only: :previews before_action :require_local!, unless: :show_previews? - content_security_policy(false) if respond_to?(:content_security_policy) + content_security_policy(false) # Including helpers here ensures that we're loading the # latest version of helpers if code-reloading is enabled diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 73a403020..18f6f2a5b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -52,6 +52,10 @@ nav_order: 5 *Stephen Nelson* +* Remove unnecessary `respond_to` checks. + + *Tiago Menegaz*, *Joel Hawksley* + * Add Content Harmony & Learn To Be to list of companies using ViewComponent. *Kane Jamison* diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 5099901a9..8f793c233 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -201,7 +201,7 @@ def initialize(*) # # This prevents an exception when rendering a partial inside of a component that has also been rendered outside # of the component. This is due to the partials compiled template method existing in the parent `view_context`, - # and not the component's `view_context`. + # and not the component's `view_context`. # # @private def render(options = {}, args = {}, &block) @@ -287,7 +287,7 @@ def request # # @private def __vc_request - @__vc_request ||= controller.request if controller.respond_to?(:request) + @__vc_request ||= controller.request end # The content passed to the component instance as a block. diff --git a/test/test_helper.rb b/test/test_helper.rb index 5ea189c8d..d2b6fd1a2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -140,18 +140,14 @@ def with_new_cache end def without_template_annotations(&block) - if ActionView::Base.respond_to?(:annotate_rendered_view_with_filenames) - old_value = ActionView::Base.annotate_rendered_view_with_filenames - ActionView::Base.annotate_rendered_view_with_filenames = false - app.reloader.reload! if defined?(app) + old_value = ActionView::Base.annotate_rendered_view_with_filenames + ActionView::Base.annotate_rendered_view_with_filenames = false + app.reloader.reload! if defined?(app) - with_new_cache(&block) + with_new_cache(&block) - ActionView::Base.annotate_rendered_view_with_filenames = old_value - app.reloader.reload! if defined?(app) - else - yield - end + ActionView::Base.annotate_rendered_view_with_filenames = old_value + app.reloader.reload! if defined?(app) end def modify_file(file, content) diff --git a/view_component.gemspec b/view_component.gemspec index 8101cfb8c..b8f306c05 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -13,16 +13,9 @@ Gem::Specification.new do |spec| spec.homepage = "https://viewcomponent.org" spec.license = "MIT" - # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' - # to allow pushing to a single host or delete this section to allow pushing to any host. - if spec.respond_to?(:metadata) - spec.metadata["allowed_push_host"] = "https://rubygems.org" - spec.metadata["source_code_uri"] = "https://github.com/viewcomponent/view_component" - spec.metadata["changelog_uri"] = "https://github.com/ViewComponent/view_component/blob/main/docs/CHANGELOG.md" - else - raise "RubyGems 2.0 or newer is required to protect against " \ - "public gem pushes." - end + spec.metadata["allowed_push_host"] = "https://rubygems.org" + spec.metadata["source_code_uri"] = "https://github.com/viewcomponent/view_component" + spec.metadata["changelog_uri"] = "https://github.com/ViewComponent/view_component/blob/main/docs/CHANGELOG.md" spec.files = Dir["LICENSE.txt", "README.md", "app/**/*", "docs/CHANGELOG.md", "lib/**/*"] spec.require_paths = ["lib"] From 6ffb5854d35ce4ece117e5220fc19db95bb0b585 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 11 Mar 2025 10:37:43 -0600 Subject: [PATCH 015/158] Remove unnecessary usage of ruby2_keywords. (#2220) * Remove unnecessary usage of ruby2_keywords * Remove unnecessary usage of ruby2_keywords * Remove unnecessary usage of ruby2_keywords * Remove unnecessary usage of ruby2_keywords * Remove unnecessary usage of ruby2_keywords * Remove unnecessary usage of ruby2_keywords. * Remove unnecessary usage of ruby2_keywords. * Remove unnecessary usage of ruby2_keywords. * add changelog * bump rubocop ruby version * what about this? * fix bug found by PVC suite? * allocations * fix lint * allocations --- .rubocop.yml | 2 +- .standard.yml | 2 +- docs/CHANGELOG.md | 4 ++++ lib/view_component/inline_template.rb | 1 - lib/view_component/slot.rb | 5 ++--- lib/view_component/slotable.rb | 31 +++++++++++---------------- lib/view_component/test_helpers.rb | 5 ++--- lib/view_component/translatable.rb | 1 - lib/view_component/use_helpers.rb | 7 +++--- 9 files changed, 26 insertions(+), 32 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 93e28a2d5..13960fd1d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,4 @@ -ruby_version: 2.5 +ruby_version: 3.2 require: - standard - "rubocop-md" diff --git a/.standard.yml b/.standard.yml index 34cd5acf3..127dd6f8d 100644 --- a/.standard.yml +++ b/.standard.yml @@ -1,3 +1,3 @@ -ruby_version: 2.5 +ruby_version: 3.2 ignore: - 'docs/CHANGELOG.md' # Rubocop doesn't like our indenting of code examples diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 18f6f2a5b..70c162731 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -52,6 +52,10 @@ nav_order: 5 *Stephen Nelson* +* Remove unnecessary usage of `ruby2_keywords`. + + *Joel Hawksley* + * Remove unnecessary `respond_to` checks. *Tiago Menegaz*, *Joel Hawksley* diff --git a/lib/view_component/inline_template.rb b/lib/view_component/inline_template.rb index 1b43d88e7..7795908ec 100644 --- a/lib/view_component/inline_template.rb +++ b/lib/view_component/inline_template.rb @@ -32,7 +32,6 @@ def method_missing(method, *args) @__vc_inline_template_defined = true end - ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) def respond_to_missing?(method, include_all = false) method.end_with?("_template") || super diff --git a/lib/view_component/slot.rb b/lib/view_component/slot.rb index 205437040..53dcc8dc8 100644 --- a/lib/view_component/slot.rb +++ b/lib/view_component/slot.rb @@ -101,10 +101,9 @@ def to_s # end # end # - def method_missing(symbol, *args, &block) - @__vc_component_instance.public_send(symbol, *args, &block) + def method_missing(symbol, *args, **kwargs, &block) + @__vc_component_instance.public_send(symbol, *args, **kwargs, &block) end - ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) def html_safe? # :nocov: diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index a8c298965..46851d32c 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -84,10 +84,9 @@ def renders_one(slot_name, callable = nil) setter_method_name = :"with_#{slot_name}" - define_method setter_method_name do |*args, &block| - set_slot(slot_name, nil, *args, &block) + define_method setter_method_name do |*args, **kwargs, &block| + set_slot(slot_name, nil, *args, **kwargs, &block) end - ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true) self::GeneratedSlotMethods.define_method slot_name do get_slot(slot_name) @@ -155,10 +154,9 @@ def renders_many(slot_name, callable = nil) setter_method_name = :"with_#{singular_name}" - define_method setter_method_name do |*args, &block| - set_slot(slot_name, nil, *args, &block) + define_method setter_method_name do |*args, **kwargs, &block| + set_slot(slot_name, nil, *args, **kwargs, &block) end - ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true) define_method :"with_#{singular_name}_content" do |content| send(setter_method_name) { content.to_s } @@ -250,10 +248,9 @@ def register_polymorphic_slot(slot_name, types, collection:) raise AlreadyDefinedPolymorphicSlotSetterError.new(setter_method_name, poly_slot_name) end - define_method(setter_method_name) do |*args, &block| - set_polymorphic_slot(slot_name, poly_type, *args, &block) + define_method(setter_method_name) do |*args, **kwargs, &block| + set_polymorphic_slot(slot_name, poly_type, *args, **kwargs, &block) end - ruby2_keywords(setter_method_name) if respond_to?(:ruby2_keywords, true) define_method :"with_#{poly_slot_name}_content" do |content| send(setter_method_name) { content.to_s } @@ -371,7 +368,7 @@ def get_slot(slot_name) end end - def set_slot(slot_name, slot_definition = nil, *args, &block) + def set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block) slot_definition ||= self.class.registered_slots[slot_name] slot = Slot.new(self) @@ -388,11 +385,11 @@ def set_slot(slot_name, slot_definition = nil, *args, &block) # If class if slot_definition[:renderable] - slot.__vc_component_instance = slot_definition[:renderable].new(*args) + slot.__vc_component_instance = slot_definition[:renderable].new(*args, **kwargs) # If class name as a string elsif slot_definition[:renderable_class_name] slot.__vc_component_instance = - self.class.const_get(slot_definition[:renderable_class_name]).new(*args) + self.class.const_get(slot_definition[:renderable_class_name]).new(*args, **kwargs) # If passed a lambda elsif slot_definition[:renderable_function] # Use `bind(self)` to ensure lambda is executed in the context of the @@ -401,11 +398,11 @@ def set_slot(slot_name, slot_definition = nil, *args, &block) renderable_function = slot_definition[:renderable_function].bind(self) renderable_value = if block - renderable_function.call(*args) do |*rargs| + renderable_function.call(*args, **kwargs) do |*rargs| view_context.capture(*rargs, &block) end else - renderable_function.call(*args) + renderable_function.call(*args, **kwargs) end # Function calls can return components, so if it's a component handle it specially @@ -427,9 +424,8 @@ def set_slot(slot_name, slot_definition = nil, *args, &block) slot end - ruby2_keywords(:set_slot) if respond_to?(:ruby2_keywords, true) - def set_polymorphic_slot(slot_name, poly_type = nil, *args, &block) + def set_polymorphic_slot(slot_name, poly_type = nil, *args, **kwargs, &block) slot_definition = self.class.registered_slots[slot_name] if !slot_definition[:collection] && (defined?(@__vc_set_slots) && @__vc_set_slots[slot_name]) @@ -438,8 +434,7 @@ def set_polymorphic_slot(slot_name, poly_type = nil, *args, &block) poly_def = slot_definition[:renderable_hash][poly_type] - set_slot(slot_name, poly_def, *args, &block) + set_slot(slot_name, poly_def, *args, **kwargs, &block) end - ruby2_keywords(:set_polymorphic_slot) if respond_to?(:ruby2_keywords, true) end end diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 31f74b469..832ad5608 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -112,12 +112,11 @@ def render_preview(name, from: __vc_test_helpers_preview_class, params: {}) # # assert_text("Hello, World!") # ``` - def render_in_view_context(*args, &block) + def render_in_view_context(...) @page = nil - @rendered_content = vc_test_controller.view_context.instance_exec(*args, &block) + @rendered_content = vc_test_controller.view_context.instance_exec(...) Nokogiri::HTML5.fragment(@rendered_content) end - ruby2_keywords(:render_in_view_context) if respond_to?(:ruby2_keywords, true) # Set the Action Pack request variant for the given block: # diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 422218207..541e28568 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "erb" -require "set" require "i18n" require "active_support/concern" diff --git a/lib/view_component/use_helpers.rb b/lib/view_component/use_helpers.rb index 8d395da26..7e0fb482c 100644 --- a/lib/view_component/use_helpers.rb +++ b/lib/view_component/use_helpers.rb @@ -12,13 +12,12 @@ def use_helper(helper_method, from: nil, prefix: false) helper_method_name = full_helper_method_name(helper_method, prefix: prefix, source: from) class_eval(<<-RUBY, __FILE__, __LINE__ + 1) - def #{helper_method_name}(*args, &block) + def #{helper_method_name}(...) raise HelpersCalledBeforeRenderError if view_context.nil? #{define_helper(helper_method: helper_method, source: from)} end RUBY - ruby2_keywords(helper_method_name) if respond_to?(:ruby2_keywords, true) end private @@ -34,9 +33,9 @@ def full_helper_method_name(helper_method, prefix: false, source: nil) end def define_helper(helper_method:, source:) - return "__vc_original_view_context.#{helper_method}(*args, &block)" unless source.present? + return "__vc_original_view_context.#{helper_method}(...)" unless source.present? - "#{source}.instance_method(:#{helper_method}).bind(self).call(*args, &block)" + "#{source}.instance_method(:#{helper_method}).bind(self).call(...)" end end end From ac31ab025719417e220ee52133a85430992b400f Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 11 Mar 2025 12:49:43 -0600 Subject: [PATCH 016/158] Standardize how we format test skip logic (#2222) * standardize skips to use app config * Update test/sandbox/test/action_view_compatibility_test.rb --- .../test/action_view_compatibility_test.rb | 18 ++++++++++++------ test/sandbox/test/rendering_test.rb | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/test/sandbox/test/action_view_compatibility_test.rb b/test/sandbox/test/action_view_compatibility_test.rb index 71348f2f7..26d040614 100644 --- a/test/sandbox/test/action_view_compatibility_test.rb +++ b/test/sandbox/test/action_view_compatibility_test.rb @@ -4,7 +4,8 @@ class ViewComponent::ActionViewCompatibilityTest < ViewComponent::TestCase def test_renders_form_for_labels_with_block_correctly - skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true" + skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled + render_inline(FormForComponent.new) assert_selector("form > div > label > input") @@ -12,7 +13,8 @@ def test_renders_form_for_labels_with_block_correctly end def test_renders_form_with_labels_with_block_correctly - skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true" + skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled + render_inline(FormWithComponent.new) assert_selector("form > div > label > input") @@ -20,7 +22,8 @@ def test_renders_form_with_labels_with_block_correctly end def test_form_without_compatibility_does_not_raise - skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true" + skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled + render_inline(IncompatibleFormComponent.new) # Bad selector should be present, at least until fixed upstream or included by default @@ -28,7 +31,8 @@ def test_form_without_compatibility_does_not_raise end def test_form_with_partial_render_works - skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true" + skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled + render_inline(FormPartialComponent.new) # Bad selector should be present, at least until fixed upstream or included by default @@ -36,13 +40,15 @@ def test_form_with_partial_render_works end def test_helper_with_content_tag - skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true" + skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled + render_inline(ContentTagComponent.new) assert_selector("div > p") end def test_including_compat_module_twice_does_not_blow_the_stack - skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true" + skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled + ActionView::Base.include(ViewComponent::CaptureCompatibility) render_inline(FormForComponent.new) assert_selector("form > div > label > input") diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 0be789ea3..647f85142 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -434,7 +434,7 @@ def test_validations_component def test_compiles_unrendered_component # The UnreferencedComponent will get compiled at boot, # but that might have been thrown away if code-reloading is enabled - skip unless Rails.env.cache_classes? + skip unless Rails.application.config.cache_classes assert UnreferencedComponent.compiled? end @@ -442,7 +442,7 @@ def test_compiles_unrendered_component def test_compiles_components_without_initializers # MissingInitializerComponent will get compiled at boot, # but that might have been thrown away if code-reloading is enabled - skip unless Rails.env.cache_classes? + skip unless Rails.application.config.cache_classes assert MissingInitializerComponent.compiled? end From 55cbc46602157e23f55d6bb496b24aca13c6eb83 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 13 Mar 2025 15:44:27 -0600 Subject: [PATCH 017/158] Loosen dependencies in v4 (#2226) * loosen concurrent_ruby dependency * loosen dependency on allocation_stats * loosen dependency on appraisal * loosen dependency on benchmark-ips * loosen dependency on minitest * loosen dependency on cuprite * loosen dependency on method_source (do we even need it any more?) * bundle update * update more dependencies * alphabetize * clean up dependencies * update appraisals * update dependencies * try to fix appraisals * add changelog --- .rubocop.yml | 1 - Appraisals | 14 +- Gemfile.lock | 204 +++++++++--------- docs/CHANGELOG.md | 4 + gemfiles/rails_7.1.gemfile | 4 +- gemfiles/rails_7.2.gemfile | 23 +- gemfiles/rails_8.0.gemfile | 5 +- gemfiles/rails_main.gemfile | 3 +- .../test/view_component_system_test.rb | 2 +- test/test_helper.rb | 4 +- view_component.gemspec | 44 ++-- 11 files changed, 134 insertions(+), 174 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 13960fd1d..7f2638640 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,3 @@ -ruby_version: 3.2 require: - standard - "rubocop-md" diff --git a/Appraisals b/Appraisals index d073a51e3..73cf11d8e 100644 --- a/Appraisals +++ b/Appraisals @@ -2,25 +2,25 @@ appraise "rails-7.1" do gem "rails", "~> 7.1" - gem "tailwindcss-rails", "~> 2.0" + gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 1" - gem "sprockets-rails", "~> 3.4.2" + gem "sprockets-rails", "~> 3" end appraise "rails-7.2" do gem "rails", "~> 7.2" - gem "tailwindcss-rails", "~> 2.0" - gem "sprockets-rails", "~> 3.4.2" + gem "tailwindcss-rails", "~> 2" + gem "sprockets-rails", "~> 3" end appraise "rails-8.0" do gem "rails", "~> 8.0" - gem "tailwindcss-rails", "~> 2.0" - gem "propshaft", "~> 1.1.0" + gem "tailwindcss-rails", "~> 2" + gem "propshaft", "~> 1" end appraise "rails-main" do gem "rails", github: "rails/rails", branch: "main" - gem "tailwindcss-rails", "~> 2.0" + gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 1" end diff --git a/Gemfile.lock b/Gemfile.lock index d2c9f0c97..acf551ff5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,35 +3,35 @@ PATH specs: view_component (3.21.0) activesupport (>= 7.1.0, < 8.1) - concurrent-ruby (= 1.3.4) - method_source (~> 1.0) + concurrent-ruby (~> 1) + method_source (~> 1) GEM remote: https://rubygems.org/ specs: - actioncable (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + actioncable (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actionmailbox (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) mail (>= 2.8.0) - actionmailer (8.0.1) - actionpack (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activesupport (= 8.0.1) + actionmailer (8.0.2) + actionpack (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activesupport (= 8.0.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.1) - actionview (= 8.0.1) - activesupport (= 8.0.1) + actionpack (8.0.2) + actionview (= 8.0.2) + activesupport (= 8.0.2) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -39,35 +39,35 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.1) - actionpack (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + actiontext (8.0.2) + actionpack (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.1) - activesupport (= 8.0.1) + actionview (8.0.2) + activesupport (= 8.0.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.1) - activesupport (= 8.0.1) + activejob (8.0.2) + activesupport (= 8.0.2) globalid (>= 0.3.6) - activemodel (8.0.1) - activesupport (= 8.0.1) - activerecord (8.0.1) - activemodel (= 8.0.1) - activesupport (= 8.0.1) + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) timeout (>= 0.4.0) - activestorage (8.0.1) - actionpack (= 8.0.1) - activejob (= 8.0.1) - activerecord (= 8.0.1) - activesupport (= 8.0.1) + activestorage (8.0.2) + actionpack (= 8.0.2) + activejob (= 8.0.2) + activerecord (= 8.0.2) + activesupport (= 8.0.2) marcel (~> 1.0) - activesupport (8.0.1) + activesupport (8.0.2) base64 benchmark (>= 0.3) bigdecimal @@ -91,7 +91,7 @@ GEM ast (2.4.2) base64 (0.2.0) benchmark (0.4.0) - benchmark-ips (2.13.0) + benchmark-ips (2.14.0) better_html (2.1.1) actionview (>= 6.0) activesupport (>= 6.0) @@ -110,17 +110,13 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - coderay (1.1.3) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.5) connection_pool (2.5.0) crass (1.0.6) cuprite (0.15.1) capybara (~> 3.0) ferrum (~> 0.15.0) date (3.4.1) - debug (1.10.0) - irb (~> 1.10) - reline (>= 0.3.8) diff-lcs (1.6.0) docile (1.4.1) drb (2.2.1) @@ -153,7 +149,7 @@ GEM jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.10.1) + json (2.10.2) language_server-protocol (3.17.0.4) lint_roller (1.1.0) logger (1.6.6) @@ -173,8 +169,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.4) - mutex_m (0.3.0) + minitest (5.25.5) net-imap (0.5.6) date net-protocol @@ -200,9 +195,6 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - pry (0.15.2) - coderay (~> 1.1) - method_source (~> 1.0) psych (5.2.3) date stringio @@ -210,7 +202,7 @@ GEM puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.11) + rack (3.1.12) rack-session (2.1.0) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -218,20 +210,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.1) - actioncable (= 8.0.1) - actionmailbox (= 8.0.1) - actionmailer (= 8.0.1) - actionpack (= 8.0.1) - actiontext (= 8.0.1) - actionview (= 8.0.1) - activejob (= 8.0.1) - activemodel (= 8.0.1) - activerecord (= 8.0.1) - activestorage (= 8.0.1) - activesupport (= 8.0.1) + rails (8.0.2) + actioncable (= 8.0.2) + actionmailbox (= 8.0.2) + actionmailer (= 8.0.2) + actionpack (= 8.0.2) + actiontext (= 8.0.2) + actionview (= 8.0.2) + activejob (= 8.0.2) + activemodel (= 8.0.2) + activerecord (= 8.0.2) + activestorage (= 8.0.2) + activesupport (= 8.0.2) bundler (>= 1.15.0) - railties (= 8.0.1) + railties (= 8.0.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -239,9 +231,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.0.1) - actionpack (= 8.0.1) - activesupport (= 8.0.1) + railties (8.0.2) + actionpack (= 8.0.2) + activesupport (= 8.0.2) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -272,9 +264,10 @@ GEM rspec-mocks (~> 3.10) rspec-support (~> 3.10) rspec-support (3.13.2) - rubocop (1.71.2) + rubocop (1.73.2) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) @@ -284,15 +277,19 @@ GEM unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.38.1) parser (>= 3.3.1.0) - rubocop-md (1.2.4) - rubocop (>= 1.45) - rubocop-performance (1.23.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) + rubocop-md (2.0.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-performance (1.24.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (1.13.0) rubyzip (2.4.1) securerandom (0.4.1) - selenium-webdriver (4.9.0) + selenium-webdriver (4.29.1) + base64 (~> 0.2) + logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -313,22 +310,22 @@ GEM sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - standard (1.45.0) + standard (1.47.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.71.0) + rubocop (~> 1.73.0) standard-custom (~> 1.0.0) - standard-performance (~> 1.6) + standard-performance (~> 1.7) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.6.0) + standard-performance (1.7.0) lint_roller (~> 1.1) - rubocop-performance (~> 1.23.0) + rubocop-performance (~> 1.24.0) stringio (3.1.5) temple (0.10.3) terminal-table (4.0.0) @@ -365,44 +362,35 @@ PLATFORMS ruby DEPENDENCIES - allocation_stats (~> 0.1.5) - appraisal (~> 2.4) - base64 - benchmark-ips (~> 2.13.0) + allocation_stats + appraisal (~> 2) + benchmark-ips (~> 2) better_html - bigdecimal bundler (~> 2) capybara (~> 3) - cuprite (~> 0.15) - debug - drb + cuprite erb_lint haml (~> 6) jbuilder (~> 2) m (~> 1) - minitest (~> 5.18) - mutex_m - net-imap - net-pop - net-smtp - propshaft (~> 1.1.0) - pry (~> 0.13) + minitest (~> 5) + propshaft (~> 1) puma (~> 6) rails (~> 8) - rake (~> 13.0) + rake (~> 13) rspec-rails (~> 5) - rubocop-md (~> 1) - selenium-webdriver (= 4.9.0) - simplecov (~> 0.22.0) - simplecov-console (~> 0.9.1) - slim (~> 5.1) - sprockets-rails (~> 3.4.2) + rubocop-md (~> 2) + selenium-webdriver (~> 4) + simplecov (< 1) + simplecov-console (< 1) + slim (~> 5) + sprockets-rails (~> 3) standard (~> 1) turbo-rails (~> 1) view_component! warning - yard (~> 0.9.34) - yard-activesupport-concern (~> 0.0.1) + yard (< 1) + yard-activesupport-concern (< 1) RUBY VERSION ruby 3.4.1p0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 70c162731..a5bb5ecf9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -52,6 +52,10 @@ nav_order: 5 *Stephen Nelson* +* Clean up project dependencies, relaxing versions of development gems. + + *Joel Hawksley* + * Remove unnecessary usage of `ruby2_keywords`. *Joel Hawksley* diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile index a6a5d6021..6cd2db1f4 100644 --- a/gemfiles/rails_7.1.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -3,8 +3,8 @@ source "https://rubygems.org" gem "rails", "~> 7.1" -gem "tailwindcss-rails", "~> 2.0" +gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 1" -gem "sprockets-rails", "~> 3.4.2" +gem "sprockets-rails", "~> 3" gemspec path: "../" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 840b6a532..18081f2c8 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -2,27 +2,8 @@ source "https://rubygems.org" -gem "capybara", "~> 3" gem "rails", "~> 7.2" -gem "rspec-rails", "~> 5" -gem "net-imap", require: false -gem "net-pop", require: false -gem "net-smtp", require: false -gem "debug" - -gem "tailwindcss-rails", "~> 2.0" - -gem "sprockets-rails", "~> 3.4.2" - -group :test do - gem "cuprite", "~> 0.15" - gem "puma", "~> 6" - gem "warning" - gem "selenium-webdriver", "4.9.0" -end - -group :development, :test do - gem "appraisal", "~> 2.5" -end +gem "tailwindcss-rails", "~> 2" +gem "sprockets-rails", "~> 3" gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile index 6280d60ba..5821b18f8 100644 --- a/gemfiles/rails_8.0.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -3,8 +3,7 @@ source "https://rubygems.org" gem "rails", "~> 8.0" -gem "tailwindcss-rails", "~> 2.0" -gem "turbo-rails", "~> 1" -gem "propshaft", "~> 1.1.0" +gem "tailwindcss-rails", "~> 2" +gem "propshaft", "~> 1" gemspec path: "../" diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index 5c92f2cf5..3fafa572c 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -3,8 +3,7 @@ source "https://rubygems.org" gem "rails", github: "rails/rails", branch: "main" -gem "tailwindcss-rails", "~> 2.0" +gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 1" -gem "propshaft", "~> 1.1.0" gemspec path: "../" diff --git a/test/sandbox/test/view_component_system_test.rb b/test/sandbox/test/view_component_system_test.rb index 263765b8c..61433b802 100644 --- a/test/sandbox/test/view_component_system_test.rb +++ b/test/sandbox/test/view_component_system_test.rb @@ -3,7 +3,7 @@ require "test_helper" class ViewComponentSystemTest < ViewComponent::SystemTestCase - driven_by :vc_cuprite + driven_by :cuprite def test_simple_js_interaction_in_browser_without_layout with_rendered_component_path(render_inline(SimpleJavascriptInteractionWithJsIncludedComponent.new)) do |path| diff --git a/test/test_helper.rb b/test/test_helper.rb index d2b6fd1a2..b8f2201c0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -43,7 +43,7 @@ def self.warn(message) # Rails registers its own driver named "cuprite" which will overwrite the one we # register here. Avoid the problem by registering the driver with a distinct name. -Capybara.register_driver(:vc_cuprite) do |app| +Capybara.register_driver(:cuprite) do |app| # Add the process_timeout option to prevent failures due to the browser # taking too long to start up. Capybara::Cuprite::Driver.new(app, {process_timeout: 60, timeout: 30}) @@ -176,7 +176,7 @@ def with_compiler_development_mode(mode) def capture_warnings(&block) [].tap do |warnings| Kernel.stub(:warn, ->(msg) { warnings << msg }) do - block.call + yield end end end diff --git a/view_component.gemspec b/view_component.gemspec index b8f306c05..55a21ce9b 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -23,46 +23,36 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.2.0" spec.add_runtime_dependency "activesupport", [">= 7.1.0", "< 8.1"] - spec.add_runtime_dependency "method_source", "~> 1.0" - spec.add_runtime_dependency "concurrent-ruby", "1.3.4" # lock version that supports Rails 6.1 - spec.add_development_dependency "allocation_stats", "~> 0.1.5" - spec.add_development_dependency "appraisal", "~> 2.4" - spec.add_development_dependency "benchmark-ips", "~> 2.13.0" + spec.add_runtime_dependency "concurrent-ruby", "~> 1" + spec.add_runtime_dependency "method_source", "~> 1" + spec.add_development_dependency "allocation_stats" + spec.add_development_dependency "appraisal", "~> 2" + spec.add_development_dependency "benchmark-ips", "~> 2" spec.add_development_dependency "better_html" spec.add_development_dependency "bundler", "~> 2" spec.add_development_dependency "capybara", "~> 3" - spec.add_development_dependency "cuprite", "~> 0.15" - spec.add_development_dependency "debug" + spec.add_development_dependency "cuprite" spec.add_development_dependency "erb_lint" spec.add_development_dependency "haml", "~> 6" spec.add_development_dependency "jbuilder", "~> 2" spec.add_development_dependency "m", "~> 1" - spec.add_development_dependency "minitest", "~> 5.18" - spec.add_development_dependency "pry", "~> 0.13" + spec.add_development_dependency "minitest", "~> 5" spec.add_development_dependency "puma", "~> 6" - spec.add_development_dependency "rake", "~> 13.0" + spec.add_development_dependency "rake", "~> 13" spec.add_development_dependency "rspec-rails", "~> 5" - spec.add_development_dependency "rubocop-md", "~> 1" - spec.add_development_dependency "selenium-webdriver", "4.9.0" - spec.add_development_dependency "sprockets-rails", "~> 3.4.2" + spec.add_development_dependency "rubocop-md", "~> 2" + spec.add_development_dependency "selenium-webdriver", "~> 4" + spec.add_development_dependency "simplecov-console", "< 1" + spec.add_development_dependency "simplecov", "< 1" + spec.add_development_dependency "slim", "~> 5" + spec.add_development_dependency "sprockets-rails", "~> 3" spec.add_development_dependency "standard", "~> 1" - spec.add_development_dependency "simplecov", "~> 0.22.0" - spec.add_development_dependency "simplecov-console", "~> 0.9.1" - spec.add_development_dependency "slim", "~> 5.1" spec.add_development_dependency "turbo-rails", "~> 1" spec.add_development_dependency "warning" - spec.add_development_dependency "yard", "~> 0.9.34" - spec.add_development_dependency "yard-activesupport-concern", "~> 0.0.1" - - spec.add_development_dependency "net-imap" - spec.add_development_dependency "net-pop" - spec.add_development_dependency "net-smtp" + spec.add_development_dependency "yard-activesupport-concern", "< 1" + spec.add_development_dependency "yard", "< 1" if RUBY_VERSION >= "3.3" - spec.add_development_dependency "base64" - spec.add_development_dependency "bigdecimal" - spec.add_development_dependency "drb" - spec.add_development_dependency "mutex_m" - spec.add_development_dependency "propshaft", "~> 1.1.0" + spec.add_development_dependency "propshaft", "~> 1" end end From 7932644351a9bc5d3658fd7cabaef19b07a6e5e2 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 13 Mar 2025 16:20:26 -0600 Subject: [PATCH 018/158] bump turbo-rails to v2 (#2227) * bump turbo-rails to v2 * add redis to appease turbo-rails * Fix final line endings * add comment * add changelog --------- Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> --- Gemfile.lock | 14 +++++++++----- docs/CHANGELOG.md | 4 ++++ test/sandbox/config/cable.yml | 1 + view_component.gemspec | 3 ++- 4 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 test/sandbox/config/cable.yml diff --git a/Gemfile.lock b/Gemfile.lock index acf551ff5..1d03512ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -243,6 +243,10 @@ GEM rake (13.2.1) rdoc (6.12.0) psych (>= 4.0.0) + redis (5.4.0) + redis-client (>= 0.22.0) + redis-client (0.24.0) + connection_pool regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) @@ -333,10 +337,9 @@ GEM thor (1.3.2) tilt (2.6.0) timeout (0.4.3) - turbo-rails (1.5.0) - actionpack (>= 6.0.0) - activejob (>= 6.0.0) - railties (>= 6.0.0) + turbo-rails (2.0.13) + actionpack (>= 7.1.0) + railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) @@ -378,6 +381,7 @@ DEPENDENCIES puma (~> 6) rails (~> 8) rake (~> 13) + redis rspec-rails (~> 5) rubocop-md (~> 2) selenium-webdriver (~> 4) @@ -386,7 +390,7 @@ DEPENDENCIES slim (~> 5) sprockets-rails (~> 3) standard (~> 1) - turbo-rails (~> 1) + turbo-rails (~> 2) view_component! warning yard (< 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a5bb5ecf9..2cc6f6e6f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -56,6 +56,10 @@ nav_order: 5 *Joel Hawksley* +* Test against `turbo-rails` `v2`. + + *Joel Hawksley* + * Remove unnecessary usage of `ruby2_keywords`. *Joel Hawksley* diff --git a/test/sandbox/config/cable.yml b/test/sandbox/config/cable.yml new file mode 100644 index 000000000..9d766d428 --- /dev/null +++ b/test/sandbox/config/cable.yml @@ -0,0 +1 @@ +# required by turbo-rails for testing diff --git a/view_component.gemspec b/view_component.gemspec index 55a21ce9b..54b250328 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -39,6 +39,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "minitest", "~> 5" spec.add_development_dependency "puma", "~> 6" spec.add_development_dependency "rake", "~> 13" + spec.add_development_dependency "redis" spec.add_development_dependency "rspec-rails", "~> 5" spec.add_development_dependency "rubocop-md", "~> 2" spec.add_development_dependency "selenium-webdriver", "~> 4" @@ -47,7 +48,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "slim", "~> 5" spec.add_development_dependency "sprockets-rails", "~> 3" spec.add_development_dependency "standard", "~> 1" - spec.add_development_dependency "turbo-rails", "~> 1" + spec.add_development_dependency "turbo-rails", "~> 2" spec.add_development_dependency "warning" spec.add_development_dependency "yard-activesupport-concern", "< 1" spec.add_development_dependency "yard", "< 1" From 5535d015d5eed9e345bf7c211e3a0d1437f7c8db Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 13 Mar 2025 16:25:25 -0600 Subject: [PATCH 019/158] register driver with unique name (#2229) Co-authored-by: Richard Macklin --- test/sandbox/test/view_component_system_test.rb | 2 +- test/test_helper.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/sandbox/test/view_component_system_test.rb b/test/sandbox/test/view_component_system_test.rb index 61433b802..75dec4f7e 100644 --- a/test/sandbox/test/view_component_system_test.rb +++ b/test/sandbox/test/view_component_system_test.rb @@ -3,7 +3,7 @@ require "test_helper" class ViewComponentSystemTest < ViewComponent::SystemTestCase - driven_by :cuprite + driven_by :system_test_driver def test_simple_js_interaction_in_browser_without_layout with_rendered_component_path(render_inline(SimpleJavascriptInteractionWithJsIncludedComponent.new)) do |path| diff --git a/test/test_helper.rb b/test/test_helper.rb index b8f2201c0..805d56daf 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -43,7 +43,7 @@ def self.warn(message) # Rails registers its own driver named "cuprite" which will overwrite the one we # register here. Avoid the problem by registering the driver with a distinct name. -Capybara.register_driver(:cuprite) do |app| +Capybara.register_driver(:system_test_driver) do |app| # Add the process_timeout option to prevent failures due to the browser # taking too long to start up. Capybara::Cuprite::Driver.new(app, {process_timeout: 60, timeout: 30}) From 38ab10f76ae4080f17069e701c61ff59aa654545 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 17 Mar 2025 15:37:42 -0600 Subject: [PATCH 020/158] use rspec-rails v7 (#2228) * use rspec-rails v6 * try v7 * add changelog --- Gemfile.lock | 18 +++++++++--------- docs/CHANGELOG.md | 4 ++++ view_component.gemspec | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1d03512ae..de50e5cb9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -259,14 +259,14 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (5.1.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - railties (>= 5.2) - rspec-core (~> 3.10) - rspec-expectations (~> 3.10) - rspec-mocks (~> 3.10) - rspec-support (~> 3.10) + rspec-rails (7.1.1) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) rspec-support (3.13.2) rubocop (1.73.2) json (~> 2.3) @@ -382,7 +382,7 @@ DEPENDENCIES rails (~> 8) rake (~> 13) redis - rspec-rails (~> 5) + rspec-rails (~> 7) rubocop-md (~> 2) selenium-webdriver (~> 4) simplecov (< 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2cc6f6e6f..f708ec230 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -60,6 +60,10 @@ nav_order: 5 *Joel Hawksley* +* Test against `rspec-rails` `v7`. + + *Joel Hawksley* + * Remove unnecessary usage of `ruby2_keywords`. *Joel Hawksley* diff --git a/view_component.gemspec b/view_component.gemspec index 54b250328..b40056c86 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -40,7 +40,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "puma", "~> 6" spec.add_development_dependency "rake", "~> 13" spec.add_development_dependency "redis" - spec.add_development_dependency "rspec-rails", "~> 5" + spec.add_development_dependency "rspec-rails", "~> 7" spec.add_development_dependency "rubocop-md", "~> 2" spec.add_development_dependency "selenium-webdriver", "~> 4" spec.add_development_dependency "simplecov-console", "< 1" From 55d36f252a429a520e9e8a0d8ef5689e21d6c83b Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 19 Mar 2025 14:43:20 -0600 Subject: [PATCH 021/158] allocations --- test/sandbox/test/rendering_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 1ce4a0520..d13e4d2e3 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 73, "3.4.2" => 75, "3.3.7" => 76} : {"3.3.7" => 75, "3.2.7" => 74} + {"3.5.0" => 81, "3.4.2" => 83, "3.3.7" => 84} : {"3.3.7" => 83, "3.2.7" => 82} assert_allocations(**allocations) do render_inline(MyComponent.new) @@ -940,7 +940,7 @@ def test_compilation_in_production_mode def test_multithread_render ViewComponent::CompileCache.cache.delete(MyComponent) Rails.env.stub :test?, true do - threads = 100.times.map do + threads = Array.new(100) do Thread.new do render_inline(MyComponent.new) From 44f34c6201d2e58be250a24d83312222eb975218 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 20 Mar 2025 11:00:09 -0600 Subject: [PATCH 022/158] Remove unnecessary #format definitions, which returned nil (#2233) --- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 6 ------ lib/view_component/collection.rb | 6 ------ 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c637068db..bf3578488 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -52,6 +52,10 @@ nav_order: 5 *Stephen Nelson* +* Remove unnecessary `#format` methods that returned `nil`. + + *Joel Hawksley* + * Clean up project dependencies, relaxing versions of development gems. *Joel Hawksley* diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b8d739808..27889cfb1 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -273,12 +273,6 @@ def view_cache_dependencies [] end - # Rails expects us to define `format` on all renderables, - # but we do not know the `format` of a ViewComponent until runtime. - def format - nil - end - # The current request. Use sparingly as doing so introduces coupling that # inhibits encapsulation & reuse, often making testing difficult. # diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index d3c13e56a..ff1359e33 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -40,12 +40,6 @@ def each(&block) components.each(&block) end - # Rails expects us to define `format` on all renderables, - # but we do not know the `format` of a ViewComponent until runtime. - def format - nil - end - private def initialize(component, object, spacer_component, **options) From 6d643c90ac396aaeb6fa5a0935d430e74268b080 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 26 Mar 2025 13:59:28 -0600 Subject: [PATCH 023/158] fix v4 ci (#2241) * fix v4 ci * try again --- Appraisals | 2 +- gemfiles/rails_main.gemfile | 2 +- test/sandbox/test/rendering_test.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Appraisals b/Appraisals index 73cf11d8e..4227804c0 100644 --- a/Appraisals +++ b/Appraisals @@ -22,5 +22,5 @@ end appraise "rails-main" do gem "rails", github: "rails/rails", branch: "main" gem "tailwindcss-rails", "~> 2" - gem "turbo-rails", "~> 1" + gem "turbo-rails", "~> 2" end diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index 3fafa572c..faeacaaf7 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -4,6 +4,6 @@ source "https://rubygems.org" gem "rails", github: "rails/rails", branch: "main" gem "tailwindcss-rails", "~> 2" -gem "turbo-rails", "~> 1" +gem "turbo-rails", "~> 2" gemspec path: "../" diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index d13e4d2e3..db7c87753 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 81, "3.4.2" => 83, "3.3.7" => 84} : {"3.3.7" => 83, "3.2.7" => 82} + {"3.5.0" => 81, "3.4.2" => 83, "3.3.7" => 84} : {"3.3.7" => 83, "3.2.8" => 82} assert_allocations(**allocations) do render_inline(MyComponent.new) From 0bcd3befac8f469229e3d741f51ded5080befb88 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 26 Mar 2025 13:59:43 -0600 Subject: [PATCH 024/158] Remove unnecessary call to `view_renderer` (#2238) --- lib/view_component/base.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 27889cfb1..185a3fc6b 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -88,9 +88,6 @@ def render_in(view_context, &block) @lookup_context ||= view_context.lookup_context - # required for path helpers in older Rails versions - @view_renderer ||= view_context.view_renderer - # For content_for @view_flow ||= view_context.view_flow From a3e52b297b762af241af534944bdc7c343a3de62 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 27 Mar 2025 09:25:52 -0600 Subject: [PATCH 025/158] Cleanup references to old Rails versions (#2243) * fix missing generator api change * remove reference to rails 6.1 * remove unnecessary comments * use Rails 8 in lint action * reference Rails 8 in example command * use class attribute default arg since we don't need to support Rails 5 * remove unnecessary newline --- .github/workflows/lint.yml | 4 ++-- docs/CONTRIBUTING.md | 2 +- docs/guide/getting-started.md | 2 +- lib/generators/view_component/component/USAGE | 2 +- lib/view_component/base.rb | 3 +-- test/sandbox/config/application.rb | 1 - test/sandbox/config/environment.rb | 3 --- 7 files changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 36b2b6faa..1316a6de0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -74,7 +74,7 @@ jobs: - uses: actions/cache@v4 with: path: vendor/bundle - key: gems-build-rails-7.1-ruby-3.2-${{ hashFiles('**/Gemfile.lock') }} + key: gems-build-rails-8-ruby-3.2-${{ hashFiles('**/Gemfile.lock') }} - name: Lint with Rubocop and ERB Lint run: | bundle config path vendor/bundle @@ -82,4 +82,4 @@ jobs: bundle exec standardrb -r "rubocop-md" bundle exec erblint **/*.html.erb env: - RAILS_VERSION: '~> 7.1.0' + RAILS_VERSION: '~> 8' diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 509ce2717..d6ef6dba7 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -64,7 +64,7 @@ bundle exec m test/view_component/YOUR_COMPONENT_test.rb:line_number Specify one of the supported versions listed in [Appraisals](https://github.com/viewcomponent/view_component/blob/main/Appraisals): ```command -bundle exec appraisal rails-5.2 rake +bundle exec appraisal rails-8.0 rake ``` ## Documentation diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 1eb8d4df8..f9ef03f31 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -92,7 +92,7 @@ def show end ``` -_Note: Content can't be passed to a component via a block in controllers. Instead, use `with_content`. In versions of Rails < 6.1, rendering a ViewComponent from a controller doesn't include the layout._ +_Note: Content can't be passed to a component via a block in controllers. Instead, use `with_content`._ When using turbo frames with [turbo-rails](https://github.com/hotwired/turbo-rails), set `content_type` as `text/html`: diff --git a/lib/generators/view_component/component/USAGE b/lib/generators/view_component/component/USAGE index 88ac4d697..bc475db11 100644 --- a/lib/generators/view_component/component/USAGE +++ b/lib/generators/view_component/component/USAGE @@ -5,7 +5,7 @@ Description: Example: ======== - bin/rails generate component Profile name age + bin/rails generate view_component Profile name age creates a Profile component and test: Component: app/components/profile_component.rb diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 185a3fc6b..66aa3b8db 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -50,8 +50,7 @@ def config delegate :content_security_policy_nonce, to: :helpers # Config option that strips trailing whitespace in templates before compiling them. - class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false - self.__vc_strip_trailing_whitespace = false # class_attribute:default doesn't work until Rails 5.2 + class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false attr_accessor :__vc_original_view_context diff --git a/test/sandbox/config/application.rb b/test/sandbox/config/application.rb index a92e48278..6ca9fe381 100644 --- a/test/sandbox/config/application.rb +++ b/test/sandbox/config/application.rb @@ -9,7 +9,6 @@ require "action_view/railtie" require "sprockets/railtie" if Rails.version.to_f < 8.0 require "propshaft" if Rails.version.to_f >= 8.0 - require "turbo-rails" # Track when different Rails frameworks get loaded. diff --git a/test/sandbox/config/environment.rb b/test/sandbox/config/environment.rb index 887cd62bd..ab70f8137 100644 --- a/test/sandbox/config/environment.rb +++ b/test/sandbox/config/environment.rb @@ -1,7 +1,4 @@ # frozen_string_literal: true -# Load the rails application require File.expand_path("../application", __FILE__) - -# Initialize the rails application Sandbox::Application.initialize! From 2fa1879a7b0fae7a861dba4aeae2f2c20c317c30 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 27 Mar 2025 09:34:52 -0600 Subject: [PATCH 026/158] BREAKING: Remove `preview_source` functionality (#2242) * BREAKING: Remove `preview_source` functionality It was awkward to vendor and serve static assets to render preview source code and Lookbook does a fantastic job at providing a storybook-like environment for working with previews. Let's remove the complexity of rendering preview source code ourselves. * fix allocations * allocations --- Gemfile.lock | 2 +- app/assets/vendor/prism.css | 4 -- app/assets/vendor/prism.min.js | 12 ----- .../view_component/preview_actions.rb | 9 +--- app/helpers/preview_helper.rb | 53 ------------------- .../view_components/_preview_source.html.erb | 17 ------ app/views/view_components/preview.html.erb | 4 -- docs/CHANGELOG.md | 4 ++ docs/api.md | 5 -- docs/guide/previews.md | 12 ----- lib/view_component/config.rb | 6 --- lib/view_component/engine.rb | 20 +------ lib/view_component/preview.rb | 6 --- .../views/layouts/component_preview.html.erb | 1 - test/sandbox/config/environments/test.rb | 1 - test/sandbox/test/config_test.rb | 1 - test/sandbox/test/integration_test.rb | 24 +-------- test/sandbox/test/preview_helper_test.rb | 52 ------------------ test/test_engine/test/config_test.rb | 1 - test/view_component/engine_test.rb | 10 ---- view_component.gemspec | 2 +- 21 files changed, 10 insertions(+), 236 deletions(-) delete mode 100644 app/assets/vendor/prism.css delete mode 100644 app/assets/vendor/prism.min.js delete mode 100644 app/helpers/preview_helper.rb delete mode 100644 app/views/view_components/_preview_source.html.erb delete mode 100644 test/sandbox/test/preview_helper_test.rb delete mode 100644 test/view_component/engine_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index 8a7d51b35..6101fe8f2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,6 @@ PATH view_component (3.22.0) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) - method_source (~> 1) GEM remote: https://rubygems.org/ @@ -395,6 +394,7 @@ DEPENDENCIES haml (~> 6) jbuilder (~> 2) m (~> 1) + method_source (~> 1) minitest (~> 5) propshaft (~> 1) puma (~> 6) diff --git a/app/assets/vendor/prism.css b/app/assets/vendor/prism.css deleted file mode 100644 index 1166d38f8..000000000 --- a/app/assets/vendor/prism.css +++ /dev/null @@ -1,4 +0,0 @@ -/* PrismJS 1.28.0 -https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+clike+erb+haml+markup-templating+ruby&plugins=line-highlight+highlight-keywords+normalize-whitespace */ -code[class*=language-],pre[class*=language-]{color:#ccc;background:0 0;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green} -pre[data-line]{position:relative;padding:1em 0 1em 3em}.line-highlight{position:absolute;left:0;right:0;padding:inherit 0;margin-top:1em;background:hsla(24,20%,50%,.08);background:linear-gradient(to right,hsla(24,20%,50%,.1) 70%,hsla(24,20%,50%,0));pointer-events:none;line-height:inherit;white-space:pre}@media print{.line-highlight{-webkit-print-color-adjust:exact;color-adjust:exact}}.line-highlight:before,.line-highlight[data-end]:after{content:attr(data-start);position:absolute;top:.4em;left:.6em;min-width:1em;padding:0 .5em;background-color:hsla(24,20%,50%,.4);color:#f4f1ef;font:bold 65%/1.5 sans-serif;text-align:center;vertical-align:.3em;border-radius:999px;text-shadow:none;box-shadow:0 1px #fff}.line-highlight[data-end]:after{content:attr(data-end);top:auto;bottom:.4em}.line-numbers .line-highlight:after,.line-numbers .line-highlight:before{content:none}pre[id].linkable-line-numbers span.line-numbers-rows{pointer-events:all}pre[id].linkable-line-numbers span.line-numbers-rows>span:before{cursor:pointer}pre[id].linkable-line-numbers span.line-numbers-rows>span:hover:before{background-color:rgba(128,128,128,.2)} diff --git a/app/assets/vendor/prism.min.js b/app/assets/vendor/prism.min.js deleted file mode 100644 index 7841a2e24..000000000 --- a/app/assets/vendor/prism.min.js +++ /dev/null @@ -1,12 +0,0 @@ -/* PrismJS 1.28.0 -https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+clike+erb+haml+markup-templating+ruby&plugins=line-highlight+highlight-keywords+normalize-whitespace */ -var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); -Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; -Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; -!function(e){e.languages.ruby=e.languages.extend("clike",{comment:{pattern:/#.*|^=begin\s[\s\S]*?^=end/m,greedy:!0},"class-name":{pattern:/(\b(?:class|module)\s+|\bcatch\s+\()[\w.\\]+|\b[A-Z_]\w*(?=\s*\.\s*new\b)/,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:BEGIN|END|alias|and|begin|break|case|class|def|define_method|defined|do|each|else|elsif|end|ensure|extend|for|if|in|include|module|new|next|nil|not|or|prepend|private|protected|public|raise|redo|require|rescue|retry|return|self|super|then|throw|undef|unless|until|when|while|yield)\b/,operator:/\.{2,3}|&\.|===||[!=]?~|(?:&&|\|\||<<|>>|\*\*|[+\-*/%<>!^&|=])=?|[?:]/,punctuation:/[(){}[\].,;]/}),e.languages.insertBefore("ruby","operator",{"double-colon":{pattern:/::/,alias:"punctuation"}});var n={pattern:/((?:^|[^\\])(?:\\{2})*)#\{(?:[^{}]|\{[^{}]*\})*\}/,lookbehind:!0,inside:{content:{pattern:/^(#\{)[\s\S]+(?=\}$)/,lookbehind:!0,inside:e.languages.ruby},delimiter:{pattern:/^#\{|\}$/,alias:"punctuation"}}};delete e.languages.ruby.function;var t="(?:"+["([^a-zA-Z0-9\\s{(\\[<=])(?:(?!\\1)[^\\\\]|\\\\[^])*\\1","\\((?:[^()\\\\]|\\\\[^]|\\((?:[^()\\\\]|\\\\[^])*\\))*\\)","\\{(?:[^{}\\\\]|\\\\[^]|\\{(?:[^{}\\\\]|\\\\[^])*\\})*\\}","\\[(?:[^\\[\\]\\\\]|\\\\[^]|\\[(?:[^\\[\\]\\\\]|\\\\[^])*\\])*\\]","<(?:[^<>\\\\]|\\\\[^]|<(?:[^<>\\\\]|\\\\[^])*>)*>"].join("|")+")",i='(?:"(?:\\\\.|[^"\\\\\r\n])*"|(?:\\b[a-zA-Z_]\\w*|[^\\s\0-\\x7F]+)[?!]?|\\$.)';e.languages.insertBefore("ruby","keyword",{"regex-literal":[{pattern:RegExp("%r"+t+"[egimnosux]{0,6}"),greedy:!0,inside:{interpolation:n,regex:/[\s\S]+/}},{pattern:/(^|[^/])\/(?!\/)(?:\[[^\r\n\]]+\]|\\.|[^[/\\\r\n])+\/[egimnosux]{0,6}(?=\s*(?:$|[\r\n,.;})#]))/,lookbehind:!0,greedy:!0,inside:{interpolation:n,regex:/[\s\S]+/}}],variable:/[@$]+[a-zA-Z_]\w*(?:[?!]|\b)/,symbol:[{pattern:RegExp("(^|[^:]):"+i),lookbehind:!0,greedy:!0},{pattern:RegExp("([\r\n{(,][ \t]*)"+i+"(?=:(?!:))"),lookbehind:!0,greedy:!0}],"method-definition":{pattern:/(\bdef\s+)\w+(?:\s*\.\s*\w+)?/,lookbehind:!0,inside:{function:/\b\w+$/,keyword:/^self\b/,"class-name":/^\w+/,punctuation:/\./}}}),e.languages.insertBefore("ruby","string",{"string-literal":[{pattern:RegExp("%[qQiIwWs]?"+t),greedy:!0,inside:{interpolation:n,string:/[\s\S]+/}},{pattern:/("|')(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|(?!\1)[^\\#\r\n])*\1/,greedy:!0,inside:{interpolation:n,string:/[\s\S]+/}},{pattern:/<<[-~]?([a-z_]\w*)[\r\n](?:.*[\r\n])*?[\t ]*\1/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<[-~]?[a-z_]\w*|\b[a-z_]\w*$/i,inside:{symbol:/\b\w+/,punctuation:/^<<[-~]?/}},interpolation:n,string:/[\s\S]+/}},{pattern:/<<[-~]?'([a-z_]\w*)'[\r\n](?:.*[\r\n])*?[\t ]*\1/i,alias:"heredoc-string",greedy:!0,inside:{delimiter:{pattern:/^<<[-~]?'[a-z_]\w*'|\b[a-z_]\w*$/i,inside:{symbol:/\b\w+/,punctuation:/^<<[-~]?'|'$/}},string:/[\s\S]+/}}],"command-literal":[{pattern:RegExp("%x"+t),greedy:!0,inside:{interpolation:n,command:{pattern:/[\s\S]+/,alias:"string"}}},{pattern:/`(?:#\{[^}]+\}|#(?!\{)|\\(?:\r\n|[\s\S])|[^\\`#\r\n])*`/,greedy:!0,inside:{interpolation:n,command:{pattern:/[\s\S]+/,alias:"string"}}}]}),delete e.languages.ruby.string,e.languages.insertBefore("ruby","number",{builtin:/\b(?:Array|Bignum|Binding|Class|Continuation|Dir|Exception|FalseClass|File|Fixnum|Float|Hash|IO|Integer|MatchData|Method|Module|NilClass|Numeric|Object|Proc|Range|Regexp|Stat|String|Struct|Symbol|TMS|Thread|ThreadGroup|Time|TrueClass)\b/,constant:/\b[A-Z][A-Z0-9_]*(?:[?!]|\b)/}),e.languages.rb=e.languages.ruby}(Prism); -!function(e){function n(e,n){return"___"+e.toUpperCase()+n+"___"}Object.defineProperties(e.languages["markup-templating"]={},{buildPlaceholders:{value:function(t,a,r,o){if(t.language===a){var c=t.tokenStack=[];t.code=t.code.replace(r,(function(e){if("function"==typeof o&&!o(e))return e;for(var r,i=c.length;-1!==t.code.indexOf(r=n(a,i));)++i;return c[i]=e,r})),t.grammar=e.languages.markup}}},tokenizePlaceholders:{value:function(t,a){if(t.language===a&&t.tokenStack){t.grammar=e.languages[a];var r=0,o=Object.keys(t.tokenStack);!function c(i){for(var u=0;u=o.length);u++){var g=i[u];if("string"==typeof g||g.content&&"string"==typeof g.content){var l=o[r],s=t.tokenStack[l],f="string"==typeof g?g:g.content,p=n(a,l),k=f.indexOf(p);if(k>-1){++r;var m=f.substring(0,k),d=new e.Token(a,e.tokenize(s,t.grammar),"language-"+a,s),h=f.substring(k+p.length),v=[];m&&v.push.apply(v,c([m])),v.push(d),h&&v.push.apply(v,c([h])),"string"==typeof g?i.splice.apply(i,[u,1].concat(v)):g.content=v}}else g.content&&c(g.content)}return i}(t.tokens)}}}})}(Prism); -!function(e){e.languages.erb={delimiter:{pattern:/^(\s*)<%=?|%>(?=\s*$)/,lookbehind:!0,alias:"punctuation"},ruby:{pattern:/\s*\S[\s\S]*/,alias:"language-ruby",inside:e.languages.ruby}},e.hooks.add("before-tokenize",(function(n){e.languages["markup-templating"].buildPlaceholders(n,"erb",/<%=?(?:[^\r\n]|[\r\n](?!=begin)|[\r\n]=begin\s(?:[^\r\n]|[\r\n](?!=end))*[\r\n]=end)+?%>/g)})),e.hooks.add("after-tokenize",(function(n){e.languages["markup-templating"].tokenizePlaceholders(n,"erb")}))}(Prism); -!function(n){n.languages.haml={"multiline-comment":{pattern:/((?:^|\r?\n|\r)([\t ]*))(?:\/|-#).*(?:(?:\r?\n|\r)\2[\t ].+)*/,lookbehind:!0,alias:"comment"},"multiline-code":[{pattern:/((?:^|\r?\n|\r)([\t ]*)(?:[~-]|[&!]?=)).*,[\t ]*(?:(?:\r?\n|\r)\2[\t ].*,[\t ]*)*(?:(?:\r?\n|\r)\2[\t ].+)/,lookbehind:!0,inside:n.languages.ruby},{pattern:/((?:^|\r?\n|\r)([\t ]*)(?:[~-]|[&!]?=)).*\|[\t ]*(?:(?:\r?\n|\r)\2[\t ].*\|[\t ]*)*/,lookbehind:!0,inside:n.languages.ruby}],filter:{pattern:/((?:^|\r?\n|\r)([\t ]*)):[\w-]+(?:(?:\r?\n|\r)(?:\2[\t ].+|\s*?(?=\r?\n|\r)))+/,lookbehind:!0,inside:{"filter-name":{pattern:/^:[\w-]+/,alias:"symbol"}}},markup:{pattern:/((?:^|\r?\n|\r)[\t ]*)<.+/,lookbehind:!0,inside:n.languages.markup},doctype:{pattern:/((?:^|\r?\n|\r)[\t ]*)!!!(?: .+)?/,lookbehind:!0},tag:{pattern:/((?:^|\r?\n|\r)[\t ]*)[%.#][\w\-#.]*[\w\-](?:\([^)]+\)|\{(?:\{[^}]+\}|[^{}])+\}|\[[^\]]+\])*[\/<>]*/,lookbehind:!0,inside:{attributes:[{pattern:/(^|[^#])\{(?:\{[^}]+\}|[^{}])+\}/,lookbehind:!0,inside:n.languages.ruby},{pattern:/\([^)]+\)/,inside:{"attr-value":{pattern:/(=\s*)(?:"(?:\\.|[^\\"\r\n])*"|[^)\s]+)/,lookbehind:!0},"attr-name":/[\w:-]+(?=\s*!?=|\s*[,)])/,punctuation:/[=(),]/}},{pattern:/\[[^\]]+\]/,inside:n.languages.ruby}],punctuation:/[<>]/}},code:{pattern:/((?:^|\r?\n|\r)[\t ]*(?:[~-]|[&!]?=)).+/,lookbehind:!0,inside:n.languages.ruby},interpolation:{pattern:/#\{[^}]+\}/,inside:{delimiter:{pattern:/^#\{|\}$/,alias:"punctuation"},ruby:{pattern:/[\s\S]+/,inside:n.languages.ruby}}},punctuation:{pattern:/((?:^|\r?\n|\r)[\t ]*)[~=\-&!]+/,lookbehind:!0}};for(var e=["css",{filter:"coffee",language:"coffeescript"},"erb","javascript","less","markdown","ruby","scss","textile"],t={},r=0,a=e.length;r ",document.body.appendChild(t),e=38===t.offsetHeight,document.body.removeChild(t)}return e}()?parseInt:parseFloat)(getComputedStyle(o).lineHeight),p=Prism.util.isActive(o,t),g=o.querySelector("code"),m=p?o:g||o,v=[],y=g.textContent.match(n),b=y?y.length+1:1,A=g&&m!=g?function(e,t){var i=getComputedStyle(e),n=getComputedStyle(t);function r(e){return+e.substr(0,e.length-2)}return t.offsetTop+r(n.borderTopWidth)+r(n.paddingTop)-r(i.paddingTop)}(o,g):0;h.forEach((function(e){var t=e.split("-"),i=+t[0],n=+t[1]||i;if(!((n=Math.min(b,n))i&&r.setAttribute("data-end",String(n)),r.style.top=(i-d-1)*f+A+"px",r.textContent=new Array(n-i+2).join(" \n")}));v.push((function(){r.style.width=o.scrollWidth+"px"})),v.push((function(){m.appendChild(r)}))}}));var P=o.id;if(p&&Prism.util.isActive(o,i)&&P){l(o,i)||v.push((function(){o.classList.add(i)}));var E=parseInt(o.getAttribute("data-start")||"1");s(".line-numbers-rows > span",o).forEach((function(e,t){var i=t+E;e.onclick=function(){var e=P+"."+i;r=!1,location.hash=e,setTimeout((function(){r=!0}),1)}}))}return function(){v.forEach(a)}}};var o=0;Prism.hooks.add("before-sanity-check",(function(e){var t=e.element.parentElement;if(u(t)){var i=0;s(".line-highlight",t).forEach((function(e){i+=e.textContent.length,e.parentNode.removeChild(e)})),i&&/^(?: \n)+$/.test(e.code.slice(-i))&&(e.code=e.code.slice(0,-i))}})),Prism.hooks.add("complete",(function e(i){var n=i.element.parentElement;if(u(n)){clearTimeout(o);var r=Prism.plugins.lineNumbers,s=i.plugins&&i.plugins.lineNumbers;l(n,t)&&r&&!s?Prism.hooks.add("line-numbers",e):(Prism.plugins.lineHighlight.highlightLines(n)(),o=setTimeout(c,1))}})),window.addEventListener("hashchange",c),window.addEventListener("resize",(function(){s("pre").filter(u).map((function(e){return Prism.plugins.lineHighlight.highlightLines(e)})).forEach(a)}))}function s(e,t){return Array.prototype.slice.call((t||document).querySelectorAll(e))}function l(e,t){return e.classList.contains(t)}function a(e){e()}function u(e){return!!(e&&/pre/i.test(e.nodeName)&&(e.hasAttribute("data-line")||e.id&&Prism.util.isActive(e,i)))}function c(){var e=location.hash.slice(1);s(".temporary.line-highlight").forEach((function(e){e.parentNode.removeChild(e)}));var t=(e.match(/\.([\d,-]+)$/)||[,""])[1];if(t&&!document.getElementById(e)){var i=e.slice(0,e.lastIndexOf(".")),n=document.getElementById(i);n&&(n.hasAttribute("data-line")||n.setAttribute("data-line",""),Prism.plugins.lineHighlight.highlightLines(n,t,"temporary ")(),r&&document.querySelector(".temporary.line-highlight").scrollIntoView())}}}(); -"undefined"!=typeof Prism&&Prism.hooks.add("wrap",(function(e){"keyword"===e.type&&e.classes.push("keyword-"+e.content)})); -!function(){if("undefined"!=typeof Prism){var e=Object.assign||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e},t={"remove-trailing":"boolean","remove-indent":"boolean","left-trim":"boolean","right-trim":"boolean","break-lines":"number",indent:"number","remove-initial-line-feed":"boolean","tabs-to-spaces":"number","spaces-to-tabs":"number"};n.prototype={setDefaults:function(t){this.defaults=e(this.defaults,t)},normalize:function(t,n){for(var r in n=e(this.defaults,n)){var i=r.replace(/-(\w)/g,(function(e,t){return t.toUpperCase()}));"normalize"!==r&&"setDefaults"!==i&&n[r]&&this[i]&&(t=this[i].call(this,t,n[r]))}return t},leftTrim:function(e){return e.replace(/^\s+/,"")},rightTrim:function(e){return e.replace(/\s+$/,"")},tabsToSpaces:function(e,t){return t=0|t||4,e.replace(/\t/g,new Array(++t).join(" "))},spacesToTabs:function(e,t){return t=0|t||4,e.replace(RegExp(" {"+t+"}","g"),"\t")},removeTrailing:function(e){return e.replace(/\s*?$/gm,"")},removeInitialLineFeed:function(e){return e.replace(/^(?:\r?\n|\r)/,"")},removeIndent:function(e){var t=e.match(/^[^\S\n\r]*(?=\S)/gm);return t&&t[0].length?(t.sort((function(e,t){return e.length-t.length})),t[0].length?e.replace(RegExp("^"+t[0],"gm"),""):e):e},indent:function(e,t){return e.replace(/^[^\S\n\r]*(?=\S)/gm,new Array(++t).join("\t")+"$&")},breakLines:function(e,t){t=!0===t?80:0|t||80;for(var n=e.split("\n"),i=0;it&&(o[l]="\n"+o[l],a=s)}n[i]=o.join("")}return n.join("\n")}},"undefined"!=typeof module&&module.exports&&(module.exports=n),Prism.plugins.NormalizeWhitespace=new n({"remove-trailing":!0,"remove-indent":!0,"left-trim":!0,"right-trim":!0}),Prism.hooks.add("before-sanity-check",(function(e){var n=Prism.plugins.NormalizeWhitespace;if((!e.settings||!1!==e.settings["whitespace-normalization"])&&Prism.util.isActive(e.element,"whitespace-normalization",!0))if(e.element&&e.element.parentNode||!e.code){var r=e.element.parentNode;if(e.code&&r&&"pre"===r.nodeName.toLowerCase()){for(var i in null==e.settings&&(e.settings={}),t)if(Object.hasOwnProperty.call(t,i)){var o=t[i];if(r.hasAttribute("data-"+i))try{var a=JSON.parse(r.getAttribute("data-"+i)||"true");typeof a===o&&(e.settings[i]=a)}catch(e){}}for(var l=r.childNodes,s="",c="",u=!1,m=0;m -
-

Source:

-
-    <% if @render_args[:component] %>
-      
-        <%= h @preview.preview_source(@example_name) %>
-      
-    <% else %>
-      <% template_data = find_template_data_for_preview_source(lookup_context: @view_renderer.lookup_context, template_identifier: @render_args[:template]) %>
-      
-        <%= h template_data[:source] %>
-      
-    <% end %>
-  
-
- diff --git a/app/views/view_components/preview.html.erb b/app/views/view_components/preview.html.erb index c12aa6eb0..44b7e8503 100644 --- a/app/views/view_components/preview.html.erb +++ b/app/views/view_components/preview.html.erb @@ -3,7 +3,3 @@ <% else %> <%= render template: @render_args[:template], locals: @render_args[:locals] || {} %> <% end %> - -<% if ViewComponent::Base.config.show_previews_source %> - <%= preview_source %> -<% end %> diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f2bb39c1b..2c62653ff 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ nav_order: 5 ## 4.0.0 +* BREAKING: Remove `preview_source` functionality. Consider using [Lookbook](https://lookbook.build/) instead. + + *Joel Hawksley* + * BREAKING: Use `Nokogiri::HTML5` instead of `Nokogiri::HTML4` for test helpers. *Noah Silvera*, *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index 0e4778345..aa96f20e4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -248,11 +248,6 @@ Defaults to `"/rails/view_components"`. Whether component previews are enabled. Defaults to `true` in development and test environments. -### `.show_previews_source` - -Whether to display source code previews in component previews. -Defaults to `false`. - ### `.test_controller` The controller used for testing components. diff --git a/docs/guide/previews.md b/docs/guide/previews.md index 54445ba33..c17f0ad59 100644 --- a/docs/guide/previews.md +++ b/docs/guide/previews.md @@ -208,18 +208,6 @@ Previews are enabled by default in test and development environments. To enable config.view_component.show_previews = false ``` -## Source previews - -A source preview is a syntax highlighted source code example of the usage of a view component, shown below the preview. -Source previews are disabled by default. To enable or disable source previews, use the `show_previews_source` option: - -```ruby -# config/environments/test.rb -config.view_component.show_previews_source = true -``` - -To render the source preview in a different location, use the view helper `preview_source` from within the preview template or preview layout. - ## Use with RSpec When using previews with RSpec, replace `test/components` with `spec/components` and update `preview_paths`: diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index c2c8d6620..ffab13a24 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -15,7 +15,6 @@ def defaults generate: default_generate_options, preview_controller: "ViewComponentsController", preview_route: "/rails/view_components", - show_previews_source: false, instrumentation_enabled: false, use_deprecated_instrumentation_name: true, view_component_path: "app/components", @@ -108,11 +107,6 @@ def defaults # The entry route for component previews. # Defaults to `"/rails/view_components"`. - # @!attribute show_previews_source - # @return [Boolean] - # Whether to display source code previews in component previews. - # Defaults to `false`. - # @!attribute instrumentation_enabled # @return [Boolean] # Whether ActiveSupport notifications are enabled. diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index 40b9d305c..7d9685c7c 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -29,7 +29,7 @@ class Engine < Rails::Engine # :nodoc: initializer "view_component.set_configs" do |app| options = app.config.view_component - %i[generate preview_controller preview_route show_previews_source].each do |config_option| + %i[generate preview_controller preview_route].each do |config_option| options[config_option] ||= ViewComponent::Base.public_send(config_option) end options.instrumentation_enabled = false if options.instrumentation_enabled.nil? @@ -40,14 +40,6 @@ class Engine < Rails::Engine # :nodoc: options.preview_paths << "#{Rails.root}/test/components/previews" if defined?(Rails.root) && Dir.exist?( "#{Rails.root}/test/components/previews" ) - - if options.show_previews_source - require "method_source" - - app.config.to_prepare do - MethodSource.instance_variable_set(:@lines_for_file, {}) - end - end end end @@ -90,16 +82,6 @@ class Engine < Rails::Engine # :nodoc: end end - initializer "static assets" do |app| - if serve_static_preview_assets?(app.config) - app.middleware.use(::ActionDispatch::Static, "#{root}/app/assets/vendor") - end - end - - def serve_static_preview_assets?(app_config) - app_config.view_component.show_previews && app_config.public_file_server.enabled - end - initializer "compiler mode" do |_app| ViewComponent::Compiler.development_mode = (Rails.env.development? || Rails.env.test?) end diff --git a/lib/view_component/preview.rb b/lib/view_component/preview.rb index 737b3601e..5e9846677 100644 --- a/lib/view_component/preview.rb +++ b/lib/view_component/preview.rb @@ -92,12 +92,6 @@ def preview_example_template_path(example) .sub(/\..*$/, "") end - # Returns the method body for the example from the preview file. - def preview_source(example) - source = instance_method(example.to_sym).source.split("\n") - source[1...(source.size - 1)].join("\n") - end - def load_previews Array(preview_paths).each do |preview_path| Dir["#{preview_path}/**/*preview.rb"].sort.each { |file| require_dependency file } diff --git a/test/sandbox/app/views/layouts/component_preview.html.erb b/test/sandbox/app/views/layouts/component_preview.html.erb index 078d140a9..37f0bddbd 100644 --- a/test/sandbox/app/views/layouts/component_preview.html.erb +++ b/test/sandbox/app/views/layouts/component_preview.html.erb @@ -1,2 +1 @@ <%= yield %> -<%= preview_source %> diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index 857bb1676..c5b5d147f 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -33,7 +33,6 @@ config.view_component.show_previews = true config.view_component.preview_paths << "#{Rails.root}/lib/component_previews" - config.view_component.show_previews_source = true config.view_component.test_controller = "IntegrationExamplesController" config.view_component.capture_compatibility_patch_enabled = ENV["CAPTURE_PATCH_ENABLED"] == "true" diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index 0a74018b4..b8e76eba4 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -12,7 +12,6 @@ def test_defaults_are_correct assert_equal @config.generate, {preview_path: ""} assert_equal @config.preview_controller, "ViewComponentsController" assert_equal @config.preview_route, "/rails/view_components" - assert_equal @config.show_previews_source, false assert_equal @config.instrumentation_enabled, false assert_equal @config.use_deprecated_instrumentation_name, true assert_equal @config.show_previews, true diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index bf567e25b..4e41470c5 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "method_source" require "test_helper" class IntegrationTest < ActionDispatch::IntegrationTest @@ -405,26 +406,6 @@ def test_preview_from_multiple_preview_paths assert_select("div", "hello,world!") end - def test_renders_preview_source_without_template - get "/rails/view_components/preview_component/default" - - assert_select ".view-component-source-example h2", "Source:" - assert_select ".view-component-source-example pre.source code" - assert_select ".language-ruby" - refute_match "<%=", response.body - refute_match "%>", response.body - end - - def test_renders_preview_source_with_template_from_layout - get "/rails/view_components/preview_source_from_layout_component/default_with_template" - - assert_select ".view-component-source-example h2", "Source:" - assert_select ".view-component-source-example pre.source code" - assert_select ".language-erb" - assert_match "<%=", response.body - assert_match "%>", response.body - end - def test_renders_collections get "/products" @@ -677,8 +658,7 @@ def test_config_options_shared_between_base_and_engine { generate: config.generate.dup.tap { |c| c.sidecar = true }, preview_controller: "SomeOtherController", - preview_route: "/some/other/route", - show_previews_source: true + preview_route: "/some/other/route" }.each do |option, value| with_config_option(option, value, config_entrypoint: config) do assert_equal(config.public_send(option), config_entrypoints.second.public_send(option)) diff --git a/test/sandbox/test/preview_helper_test.rb b/test/sandbox/test/preview_helper_test.rb deleted file mode 100644 index d159f881a..000000000 --- a/test/sandbox/test/preview_helper_test.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -# rubocop:disable Style/MixinUsage -include PreviewHelper -# rubocop:enable Style/MixinUsage -class PreviewHelperTest < ActiveSupport::TestCase - def test_returns_template_data_with_no_template - template_identifier = "preview/no_template" - - expected_template_source = "expected_template" - mock_template = Minitest::Mock.new - mock_template.expect(:source, expected_template_source) - mock_template.expect(:source, expected_template_source) - mock_template.expect(:identifier, "unknown") - - lookup_context = Minitest::Mock.new - lookup_context.expect(:find_template, mock_template, [template_identifier]) - - template_data = PreviewHelper.find_template_data_for_preview_source( - lookup_context: lookup_context, - template_identifier: template_identifier - ) - - assert_equal(template_data[:source], "expected_template") - assert_equal(template_data[:prism_language_name], "ruby") - end - - def test_returns_template_data_with_template_of_different_languages - template_identifier = "preview/template" - - expected_template_source = "expected_template" - - PreviewHelper::AVAILABLE_PRISM_LANGUAGES.each do |language| - mock_template = Minitest::Mock.new - mock_template.expect(:source, expected_template_source) - mock_template.expect(:source, expected_template_source) - mock_template.expect(:identifier, "html.#{language}") - - lookup_context = Minitest::Mock.new - lookup_context.expect(:find_template, mock_template, [template_identifier]) - - template_data = PreviewHelper.find_template_data_for_preview_source( - lookup_context: lookup_context, - template_identifier: template_identifier - ) - - assert_equal(template_data[:source], "expected_template") - assert_equal(template_data[:prism_language_name], language) - end - end -end diff --git a/test/test_engine/test/config_test.rb b/test/test_engine/test/config_test.rb index 5300cadcd..0f93a2fca 100644 --- a/test/test_engine/test/config_test.rb +++ b/test/test_engine/test/config_test.rb @@ -12,7 +12,6 @@ def test_defaults_are_correct assert_equal @config.generate, {preview_path: ""} assert_equal @config.preview_controller, "ViewComponentsController" assert_equal @config.preview_route, "/rails/view_components" - assert_equal @config.show_previews_source, false assert_equal @config.instrumentation_enabled, false assert_equal @config.use_deprecated_instrumentation_name, true assert_equal @config.show_previews, true diff --git a/test/view_component/engine_test.rb b/test/view_component/engine_test.rb deleted file mode 100644 index 5c5c03726..000000000 --- a/test/view_component/engine_test.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class ViewComponent::EngineTest < ActionDispatch::IntegrationTest - def test_serve_static_previews? - app.config.public_file_server.enabled = false - refute ViewComponent::Engine.instance.serve_static_preview_assets?(app.config) - end -end diff --git a/view_component.gemspec b/view_component.gemspec index b40056c86..7960e8e66 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -24,7 +24,6 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "activesupport", [">= 7.1.0", "< 8.1"] spec.add_runtime_dependency "concurrent-ruby", "~> 1" - spec.add_runtime_dependency "method_source", "~> 1" spec.add_development_dependency "allocation_stats" spec.add_development_dependency "appraisal", "~> 2" spec.add_development_dependency "benchmark-ips", "~> 2" @@ -36,6 +35,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "haml", "~> 6" spec.add_development_dependency "jbuilder", "~> 2" spec.add_development_dependency "m", "~> 1" + spec.add_development_dependency "method_source", "~> 1" spec.add_development_dependency "minitest", "~> 5" spec.add_development_dependency "puma", "~> 6" spec.add_development_dependency "rake", "~> 13" From 43492746476b8f1a39d3a82a6ab39348f7ee5043 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 27 Mar 2025 14:32:20 -0600 Subject: [PATCH 027/158] bundle update --- Gemfile.lock | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2693fbe7b..91d7834a1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -116,9 +116,6 @@ GEM capybara (~> 3.0) ferrum (~> 0.15.0) date (3.4.1) - debug (1.10.0) - irb (~> 1.10) - reline (>= 0.3.8) diff-lcs (1.6.1) docile (1.4.1) drb (2.2.1) @@ -181,21 +178,21 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.5-aarch64-linux-gnu) + nokogiri (1.18.6-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.5-aarch64-linux-musl) + nokogiri (1.18.6-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.5-arm-linux-gnu) + nokogiri (1.18.6-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.5-arm-linux-musl) + nokogiri (1.18.6-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.5-arm64-darwin) + nokogiri (1.18.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.5-x86_64-darwin) + nokogiri (1.18.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.5-x86_64-linux-gnu) + nokogiri (1.18.6-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.5-x86_64-linux-musl) + nokogiri (1.18.6-x86_64-linux-musl) racc (~> 1.4) parallel (1.26.3) parser (3.3.7.3) @@ -294,10 +291,12 @@ GEM rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.1) - parser (>= 3.3.1.0) - rubocop-md (1.2.4) - rubocop (>= 1.45) + rubocop-ast (1.43.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-md (2.0.0) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) rubocop-performance (1.24.0) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) @@ -305,7 +304,7 @@ GEM ruby-progressbar (1.13.0) rubyzip (2.4.1) securerandom (0.4.1) - selenium-webdriver (4.29.1) + selenium-webdriver (4.30.1) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) From 5be2918c700eb02d6b336567fb83938b8095e74e Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 27 Mar 2025 15:17:55 -0600 Subject: [PATCH 028/158] remove unused code path in template.rb (#2247) --- lib/view_component/template.rb | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index 969a2e8ee..b4313fe50 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -3,7 +3,6 @@ module ViewComponent class Template DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true) - DataNoSource = Struct.new(:source, :identifier, :type, keyword_init: true) attr_reader :details @@ -149,17 +148,7 @@ def compiled_source format = self.format || ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT type = ActionView::Template::Types[format] - if handler.method(:call).parameters.length > 1 - handler.call( - DataWithSource.new(format: format, identifier: @path, short_identifier: short_identifier, type: type), - this_source - ) - # :nocov: - # TODO: Remove in v4 - else - handler.call(DataNoSource.new(source: this_source, identifier: @path, type: type)) - end - # :nocov: + handler.call(DataWithSource.new(format:, identifier: @path, short_identifier:, type:), this_source) end end end From c7f5dd9d1e1b6c330bcb0a345497f62a6866a10c Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 1 Apr 2025 11:21:27 -0600 Subject: [PATCH 029/158] remove unnecessary nocov (#2251) * remove unncessary nocov * remove unused code not caught due to nocov * fix allocations * remove nocov * remove untested, undocumented DEBUG flag * remove unnecessary nocovs * add test coverage for preview helper logic * remove unnecessary nocov * remove last nocovs * add nocovs for now * add changelog * allocations --- docs/CHANGELOG.md | 4 ++++ docs/api.md | 8 -------- lib/view_component/engine.rb | 2 -- lib/view_component/errors.rb | 18 ------------------ .../rails/tasks/view_component.rake | 2 -- lib/view_component/slot.rb | 2 -- lib/view_component/test_helpers.rb | 18 ++---------------- .../test/components/described_class_test.rb | 19 +++++++++++++++++++ .../components/nil_described_class_test.rb | 19 +++++++++++++++++++ test/sandbox/test/rendering_test.rb | 2 +- 10 files changed, 45 insertions(+), 49 deletions(-) create mode 100644 test/sandbox/test/components/described_class_test.rb create mode 100644 test/sandbox/test/components/nil_described_class_test.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2c62653ff..ad17bd91b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -56,6 +56,10 @@ nav_order: 5 *Stephen Nelson* +* Add test coverage for uncovered code. + + *Joel Hawksley* + * Remove unnecessary `#format` methods that returned `nil`. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index aa96f20e4..1e81b3037 100644 --- a/docs/api.md +++ b/docs/api.md @@ -463,20 +463,12 @@ To fix this issue, create a template for the example. Inline templates can only be defined once per-component. -### `MultipleMatchingTemplatesForPreviewError` - -Found multiple templates for TEMPLATE_IDENTIFIER. - ### `NilWithContentError` No content provided to `#with_content` for ViewComponent::NilWithContentError. To fix this issue, pass a value. -### `NoMatchingTemplatesForPreviewError` - -Found 0 matches for templates for TEMPLATE_IDENTIFIER. - ### `RedefinedSlotError` COMPONENT declares the SLOT_NAME slot multiple times. diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index 7d9685c7c..b8286efa5 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -59,13 +59,11 @@ class Engine < Rails::Engine # :nodoc: end end - # :nocov: initializer "view_component.enable_capture_patch" do |app| ActiveSupport.on_load(:view_component) do ActionView::Base.include(ViewComponent::CaptureCompatibility) if app.config.view_component.capture_compatibility_patch_enabled end end - # :nocov: initializer "view_component.set_autoload_paths" do |app| options = app.config.view_component diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index 8974bb8db..7b5ded9a1 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -218,24 +218,6 @@ class ControllerCalledBeforeRenderError < BaseError "`#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)." end - # :nocov: - class NoMatchingTemplatesForPreviewError < StandardError - MESSAGE = "Found 0 matches for templates for TEMPLATE_IDENTIFIER." - - def initialize(template_identifier) - super(MESSAGE.gsub("TEMPLATE_IDENTIFIER", template_identifier)) - end - end - - class MultipleMatchingTemplatesForPreviewError < StandardError - MESSAGE = "Found multiple templates for TEMPLATE_IDENTIFIER." - - def initialize(template_identifier) - super(MESSAGE.gsub("TEMPLATE_IDENTIFIER", template_identifier)) - end - end - # :nocov: - class SystemTestControllerOnlyAllowedInTestError < BaseError MESSAGE = "ViewComponent SystemTest controller must only be called in a test environment for security reasons." end diff --git a/lib/view_component/rails/tasks/view_component.rake b/lib/view_component/rails/tasks/view_component.rake index ae8662cd1..40ec386f2 100644 --- a/lib/view_component/rails/tasks/view_component.rake +++ b/lib/view_component/rails/tasks/view_component.rake @@ -4,7 +4,6 @@ task stats: "view_component:statsetup" namespace :view_component do task :statsetup do - # :nocov: require "rails/code_statistics" if Rails.root.join(ViewComponent::Base.view_component_path).directory? @@ -15,6 +14,5 @@ namespace :view_component do ::STATS_DIRECTORIES << ["ViewComponent tests", "test/components"] CodeStatistics::TEST_TYPES << "ViewComponent tests" end - # :nocov: end end diff --git a/lib/view_component/slot.rb b/lib/view_component/slot.rb index 53dcc8dc8..4a52e34ae 100644 --- a/lib/view_component/slot.rb +++ b/lib/view_component/slot.rb @@ -106,9 +106,7 @@ def method_missing(symbol, *args, **kwargs, &block) end def html_safe? - # :nocov: to_s.html_safe? - # :nocov: end def respond_to_missing?(symbol, include_all = false) diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 832ad5608..0a9cb8847 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -18,18 +18,7 @@ def refute_component_rendered def assert_component_rendered assert_selector("body") end - rescue LoadError - # We don't have a test case for running an application without capybara installed. - # It's probably fine to leave this without coverage. - # :nocov: - if ENV["DEBUG"] - warn( - "WARNING in `ViewComponent::TestHelpers`: Add `capybara` " \ - "to Gemfile to use Capybara assertions." - ) - end - - # :nocov: + rescue LoadError # We don't have a test case for running an application without capybara installed. end # Returns the result of a render_inline call. @@ -278,11 +267,9 @@ def __vc_test_helpers_build_controller(klass) def __vc_test_helpers_preview_class result = if respond_to?(:described_class) - # :nocov: - raise "`render_preview` expected a described_class, but it is nil." if described_class.nil? + raise ArgumentError.new("`render_preview` expected a described_class, but it is nil.") if described_class.nil? "#{described_class}Preview" - # :nocov: else self.class.name.gsub("Test", "Preview") end @@ -290,6 +277,5 @@ def __vc_test_helpers_preview_class rescue NameError raise NameError, "`render_preview` expected to find #{result}, but it does not exist." end - # :nocov: end end diff --git a/test/sandbox/test/components/described_class_test.rb b/test/sandbox/test/components/described_class_test.rb new file mode 100644 index 000000000..1629a6850 --- /dev/null +++ b/test/sandbox/test/components/described_class_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" + +class DescribedClassTest < ViewComponent::TestCase + def setup + ViewComponent::Preview.load_previews + end + + def described_class + MyComponent + end + + def test_render_preview + render_preview(:default) + + assert_selector("div", text: "hello,world!") + end +end diff --git a/test/sandbox/test/components/nil_described_class_test.rb b/test/sandbox/test/components/nil_described_class_test.rb new file mode 100644 index 000000000..335290fe3 --- /dev/null +++ b/test/sandbox/test/components/nil_described_class_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "test_helper" + +class NilDescribedClassTest < ViewComponent::TestCase + def setup + ViewComponent::Preview.load_previews + end + + def described_class + nil + end + + def test_render_preview + assert_raises(ArgumentError) do + render_preview(:default) + end + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index db7c87753..18bc72458 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 81, "3.4.2" => 83, "3.3.7" => 84} : {"3.3.7" => 83, "3.2.8" => 82} + {"3.5.0" => 77, "3.4.2" => 83, "3.3.7" => 84} : {"3.3.7" => 83, "3.2.8" => 82} assert_allocations(**allocations) do render_inline(MyComponent.new) From 08af87894db5b0f2bfffdfe768a5a06ab8e6f524 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 1 Apr 2025 11:32:41 -0600 Subject: [PATCH 030/158] No need to run test suite twice (#2252) * remove enable_reloading flag * add changelog --- .github/workflows/ci.yml | 3 --- docs/CHANGELOG.md | 4 ++++ test/sandbox/config/environments/test.rb | 4 +--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d29065585..f4f223e8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,12 +69,9 @@ jobs: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true - name: Build and test with Rake - # Code-reloading isn't compatible with simplecov, so we need to run once - # to collect coverage, and again to test reloads. run: | bundle exec appraisal rails-${{ matrix.rails_version }} bundle MEASURE_COVERAGE=true bundle exec appraisal rails-${{ matrix.rails_version }} rake - ENABLE_RELOADING=true bundle exec appraisal rails-${{ matrix.rails_version }} rake env: RAISE_ON_WARNING: 1 RAILS_VERSION: ${{ matrix.rails_version }} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ad17bd91b..462d210ee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -56,6 +56,10 @@ nav_order: 5 *Stephen Nelson* +* Remove unnecessary ENABLE_RELOADING test suite flag. + + *Joel Hawksley* + * Add test coverage for uncovered code. *Joel Hawksley* diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index c5b5d147f..22e3cad32 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -16,9 +16,7 @@ # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! - # `cache_classes=false` is necessary to test code-reloading for people using VC in development. - # However, it's incompatible with collecting simplecov coverage reports. - config.cache_classes = !ENV["ENABLE_RELOADING"] + config.cache_classes = false # Show full error reports and disable caching config.consider_all_requests_local = true From 6a8ec4ada1ee95c7774d0c6b22c8d182a7bb3cac Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 1 Apr 2025 15:01:34 -0600 Subject: [PATCH 031/158] [v4] Remove final nocov (#2253) * confirm nocov is needed * turn on instrumentation in tests * allocations * remove instrumentation option, update allocations * allocations * allocations * allocations * revert changes * allocations * try using include? * use prepend * turn of instrumentation for allocations test * allocations * does this fix coverage * add changelog * improve test coverage --- docs/CHANGELOG.md | 4 +++ docs/api.md | 7 ----- docs/guide/instrumentation.md | 3 -- lib/view_component/config.rb | 8 ----- lib/view_component/engine.rb | 8 ----- lib/view_component/instrumentation.rb | 14 +++------ test/sandbox/config/environments/test.rb | 4 +-- test/sandbox/test/config_test.rb | 2 -- test/sandbox/test/instrumentation_test.rb | 36 ++++++++++++----------- test/sandbox/test/rendering_test.rb | 8 +++-- test/test_engine/test/config_test.rb | 1 - test/test_helper.rb | 8 +++++ 12 files changed, 42 insertions(+), 61 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 462d210ee..7625d2c85 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ nav_order: 5 ## 4.0.0 +* BREAKING: Remove `use_deprecated_instrumentation_name` configuration option. Events will always use `render.view_component` name. + + *Joel Hawksley* + * BREAKING: Remove `preview_source` functionality. Consider using [Lookbook](https://lookbook.build/) instead. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index 1e81b3037..b1b284ec0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -254,13 +254,6 @@ The controller used for testing components. Can also be configured on a per-test basis using `#with_controller_class`. Defaults to `ApplicationController`. -### `.use_deprecated_instrumentation_name` - -Whether ActiveSupport Notifications use the private name `"!render.view_component"` -or are made more publicly available via `"render.view_component"`. -Will default to `false` in next major version. -Defaults to `true`. - ### `.view_component_path` The path in which components, their templates, and their sidecars should diff --git a/docs/guide/instrumentation.md b/docs/guide/instrumentation.md index c1230a212..af533ceac 100644 --- a/docs/guide/instrumentation.md +++ b/docs/guide/instrumentation.md @@ -15,11 +15,8 @@ To enable ActiveSupport notifications, use the `instrumentation_enabled` option: # config/application.rb # Enable ActiveSupport notifications for all ViewComponents config.view_component.instrumentation_enabled = true -config.view_component.use_deprecated_instrumentation_name = false ``` -Setting `use_deprecated_instrumentation_name` configures the event name. If `false` the name is `"render.view_component"`. If `true` (default) the deprecated `"!render.view_component"` will be used. - Subscribe to the event: ```ruby diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index ffab13a24..9a65a746a 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -16,7 +16,6 @@ def defaults preview_controller: "ViewComponentsController", preview_route: "/rails/view_components", instrumentation_enabled: false, - use_deprecated_instrumentation_name: true, view_component_path: "app/components", component_parent_class: nil, show_previews: Rails.env.development? || Rails.env.test?, @@ -112,13 +111,6 @@ def defaults # Whether ActiveSupport notifications are enabled. # Defaults to `false`. - # @!attribute use_deprecated_instrumentation_name - # @return [Boolean] - # Whether ActiveSupport Notifications use the private name `"!render.view_component"` - # or are made more publicly available via `"render.view_component"`. - # Will default to `false` in next major version. - # Defaults to `true`. - # @!attribute view_component_path # @return [String] # The path in which components, their templates, and their sidecars should diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index b8286efa5..56502f993 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -46,15 +46,7 @@ class Engine < Rails::Engine # :nodoc: initializer "view_component.enable_instrumentation" do |app| ActiveSupport.on_load(:view_component) do if app.config.view_component.instrumentation_enabled.present? - # :nocov: Re-executing the below in tests duplicates initializers and causes order-dependent failures. ViewComponent::Base.prepend(ViewComponent::Instrumentation) - if app.config.view_component.use_deprecated_instrumentation_name - ViewComponent::Deprecation.deprecation_warning( - "!render.view_component", - "Use the new instrumentation key `render.view_component` instead. See https://viewcomponent.org/guide/instrumentation.html" - ) - end - # :nocov: end end end diff --git a/lib/view_component/instrumentation.rb b/lib/view_component/instrumentation.rb index d63efd283..2b50d84f2 100644 --- a/lib/view_component/instrumentation.rb +++ b/lib/view_component/instrumentation.rb @@ -5,12 +5,14 @@ module ViewComponent # :nodoc: module Instrumentation def self.included(mod) - mod.prepend(self) unless ancestors.include?(ViewComponent::Instrumentation) + mod.prepend(self) unless self <= ViewComponent::Instrumentation end def render_in(view_context, &block) + return super if !Rails.application.config.view_component.instrumentation_enabled.present? + ActiveSupport::Notifications.instrument( - notification_name, + "render.view_component", { name: self.class.name, identifier: self.class.identifier @@ -19,13 +21,5 @@ def render_in(view_context, &block) super end end - - private - - def notification_name - return "!render.view_component" if ViewComponent::Base.config.use_deprecated_instrumentation_name - - "render.view_component" - end end end diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index 22e3cad32..d74f220fd 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -16,7 +16,7 @@ # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! - config.cache_classes = false + config.cache_classes = true # Show full error reports and disable caching config.consider_all_requests_local = true @@ -29,7 +29,7 @@ config.action_controller.allow_forgery_protection = false config.view_component.show_previews = true - + config.view_component.instrumentation_enabled = true config.view_component.preview_paths << "#{Rails.root}/lib/component_previews" config.view_component.test_controller = "IntegrationExamplesController" config.view_component.capture_compatibility_patch_enabled = ENV["CAPTURE_PATCH_ENABLED"] == "true" diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index b8e76eba4..a58952117 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -13,8 +13,6 @@ def test_defaults_are_correct assert_equal @config.preview_controller, "ViewComponentsController" assert_equal @config.preview_route, "/rails/view_components" assert_equal @config.instrumentation_enabled, false - assert_equal @config.use_deprecated_instrumentation_name, true - assert_equal @config.show_previews, true assert_equal @config.preview_paths, ["#{Rails.root}/test/components/previews"] end diff --git a/test/sandbox/test/instrumentation_test.rb b/test/sandbox/test/instrumentation_test.rb index b2d4d0490..49013c853 100644 --- a/test/sandbox/test/instrumentation_test.rb +++ b/test/sandbox/test/instrumentation_test.rb @@ -3,30 +3,32 @@ require "test_helper" class InstrumentationTest < ViewComponent::TestCase - def test_instrumentation - with_config_option(:use_deprecated_instrumentation_name, false) do + def test_instrumentation_for_include + events = [] + ActiveSupport::Notifications.subscribe("render.view_component") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + render_inline(InstrumentationComponent.new) + + assert_selector("div", text: "hello,world!") + assert_equal(events.size, 1) + assert_equal("render.view_component", events[0].name) + assert_equal(events[0].payload[:name], "InstrumentationComponent") + assert_match("app/components/instrumentation_component.rb", events[0].payload[:identifier]) + end + + def test_instrumentation_disabled + with_instrumentation_enabled_option(false) do events = [] ActiveSupport::Notifications.subscribe("render.view_component") do |*args| events << ActiveSupport::Notifications::Event.new(*args) end + render_inline(InstrumentationComponent.new) assert_selector("div", text: "hello,world!") - assert_equal(events.size, 1) - assert_equal("render.view_component", events[0].name) - assert_equal(events[0].payload[:name], "InstrumentationComponent") - assert_match("app/components/instrumentation_component.rb", events[0].payload[:identifier]) + assert_equal(events.size, 0) end end - - def test_instrumentation_with_deprecated_name - events = [] - ActiveSupport::Notifications.subscribe("!render.view_component") do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end - render_inline(InstrumentationComponent.new) - - assert_equal(events.size, 1) - assert_equal("!render.view_component", events[0].name) - end end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 18bc72458..0d8300690 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,10 +16,12 @@ def test_render_inline_allocations MyComponent.ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 77, "3.4.2" => 83, "3.3.7" => 84} : {"3.3.7" => 83, "3.2.8" => 82} + {"3.5.0" => 79, "3.4.2" => 85, "3.3.7" => 86} : {"3.3.7" => 85, "3.2.8" => 84} - assert_allocations(**allocations) do - render_inline(MyComponent.new) + with_instrumentation_enabled_option(false) do + assert_allocations(**allocations) do + render_inline(MyComponent.new) + end end assert_selector("div", text: "hello,world!") diff --git a/test/test_engine/test/config_test.rb b/test/test_engine/test/config_test.rb index 0f93a2fca..cff1cf8da 100644 --- a/test/test_engine/test/config_test.rb +++ b/test/test_engine/test/config_test.rb @@ -13,7 +13,6 @@ def test_defaults_are_correct assert_equal @config.preview_controller, "ViewComponentsController" assert_equal @config.preview_route, "/rails/view_components" assert_equal @config.instrumentation_enabled, false - assert_equal @config.use_deprecated_instrumentation_name, true assert_equal @config.show_previews, true assert_equal @config.preview_paths, ["#{TestEngine::Engine.root}/test/components/previews"] end diff --git a/test/test_helper.rb b/test/test_helper.rb index 805d56daf..220d672ce 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -114,6 +114,14 @@ def with_generate_option(config_option, value) Rails.application.config.view_component.generate[config_option] = old_value end +def with_instrumentation_enabled_option(value) + old_value = Rails.application.config.view_component.instrumentation_enabled + Rails.application.config.view_component.instrumentation_enabled = value + yield +ensure + Rails.application.config.view_component.instrumentation_enabled = old_value +end + def with_generate_sidecar(enabled, &block) with_generate_option(:sidecar, enabled, &block) end From d076f80e06d9defcdb478a39fe1d2afb97d7c2f5 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 1 Apr 2025 15:14:49 -0600 Subject: [PATCH 032/158] ViewComponentsSystemTestController only exists in test env (#2255) --- .../view_components_system_test_controller.rb | 39 +++++++++---------- docs/CHANGELOG.md | 4 ++ docs/api.md | 4 -- lib/view_component/errors.rb | 4 -- lib/view_component/system_test_helpers.rb | 3 +- 5 files changed, 23 insertions(+), 31 deletions(-) diff --git a/app/controllers/view_components_system_test_controller.rb b/app/controllers/view_components_system_test_controller.rb index 38f37e0a2..5e2f9e7f2 100644 --- a/app/controllers/view_components_system_test_controller.rb +++ b/app/controllers/view_components_system_test_controller.rb @@ -1,30 +1,27 @@ # frozen_string_literal: true -class ViewComponentsSystemTestController < ActionController::Base # :nodoc: - before_action :validate_test_env - before_action :validate_file_path +if Rails.env.test? + class ViewComponentsSystemTestController < ActionController::Base # :nodoc: + before_action :validate_file_path - def self.temp_dir - @_tmpdir ||= FileUtils.mkdir_p("./tmp/view_components/").first - end - - def system_test_entrypoint - render file: @path - end + def self.temp_dir + @_tmpdir ||= FileUtils.mkdir_p("./tmp/view_components/").first + end - private + def system_test_entrypoint + render file: @path + end - def validate_test_env - raise ViewComponent::SystemTestControllerOnlyAllowedInTestError unless Rails.env.test? - end + private - # Ensure that the file path is valid and doesn't target files outside - # the expected directory (e.g. via a path traversal or symlink attack) - def validate_file_path - base_path = ::File.realpath(self.class.temp_dir) - @path = ::File.realpath(params.permit(:file)[:file], base_path) - unless @path.start_with?(base_path) - raise ViewComponent::SystemTestControllerNefariousPathError + # Ensure that the file path is valid and doesn't target files outside + # the expected directory (e.g. via a path traversal or symlink attack) + def validate_file_path + base_path = ::File.realpath(self.class.temp_dir) + @path = ::File.realpath(params.permit(:file)[:file], base_path) + unless @path.start_with?(base_path) + raise ViewComponent::SystemTestControllerNefariousPathError + end end end end diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7625d2c85..d383c7a76 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -60,6 +60,10 @@ nav_order: 5 *Stephen Nelson* +* `ViewComponentsSystemTestController` should not exist outside of test environment + + *Joel Hawksley* + * Remove unnecessary ENABLE_RELOADING test suite flag. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index b1b284ec0..2a4676579 100644 --- a/docs/api.md +++ b/docs/api.md @@ -496,10 +496,6 @@ To fix this issue, choose a different name. ViewComponent SystemTest controller attempted to load a file outside of the expected directory. -### `SystemTestControllerOnlyAllowedInTestError` - -ViewComponent SystemTest controller must only be called in a test environment for security reasons. - ### `TranslateCalledBeforeRenderError` `#translate` can't be used during initialization as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index 7b5ded9a1..f101a91f5 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -218,10 +218,6 @@ class ControllerCalledBeforeRenderError < BaseError "`#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void)." end - class SystemTestControllerOnlyAllowedInTestError < BaseError - MESSAGE = "ViewComponent SystemTest controller must only be called in a test environment for security reasons." - end - class SystemTestControllerNefariousPathError < BaseError MESSAGE = "ViewComponent SystemTest controller attempted to load a file outside of the expected directory." end diff --git a/lib/view_component/system_test_helpers.rb b/lib/view_component/system_test_helpers.rb index 351df67fd..c5677e63b 100644 --- a/lib/view_component/system_test_helpers.rb +++ b/lib/view_component/system_test_helpers.rb @@ -4,7 +4,6 @@ module ViewComponent module SystemTestHelpers include TestHelpers - # # Returns a block that can be used to visit the path of the inline rendered component. # @param fragment [Nokogiri::Fragment] The fragment returned from `render_inline`. # @param layout [String] The (optional) layout to use. @@ -18,7 +17,7 @@ def with_rendered_component_path(fragment, layout: false, &block) file.write(vc_test_controller.render_to_string(html: fragment.to_html.html_safe, layout: layout)) file.rewind - block.call("/_system_test_entrypoint?file=#{file.path.split("/").last}") + yield("/_system_test_entrypoint?file=#{file.path.split("/").last}") ensure file.unlink end From 9ef769ee8a39a881442998a606cb30026e4ff0c0 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 3 Apr 2025 08:43:07 -0600 Subject: [PATCH 033/158] Rename private and internal methods to indicate they are not part of public API (#2257) * clean up constants * more private constants * make .compiled? private * make .compile name private * make compiler method private * make more private methods * i18n_backend is internal-only * i18n_backend should be internal-only * i18n_scope is internal-only * i18n_key is internal_only * use class_attribute default * compiler mode is an internal flag * Collection#components can be private * remove unused ComponentError class * inline template lang is internal * load_previews is internal * __vc_registered_slots is private * register_polymorphic_slot can be private * add changelog * lint * fix typo * undo registered_slots change for now * vale * Update docs/CHANGELOG.md * Update docs/CHANGELOG.md --- docs/CHANGELOG.md | 4 ++ lib/view_component.rb | 1 - lib/view_component/base.rb | 55 +++++++++---------- lib/view_component/collection.rb | 20 +++---- lib/view_component/compiler.rb | 12 ++-- lib/view_component/component_error.rb | 6 -- lib/view_component/engine.rb | 4 +- lib/view_component/inline_template.rb | 4 +- lib/view_component/preview.rb | 5 +- lib/view_component/slotable.rb | 36 ++++++------ lib/view_component/template.rb | 7 ++- lib/view_component/translatable.rb | 46 ++++++++-------- .../previews/preview_component_spec.rb | 4 +- .../test/components/described_class_test.rb | 2 +- .../test/components/my_component_test.rb | 2 +- .../components/nil_described_class_test.rb | 2 +- .../test/components/render_preview_test.rb | 2 +- test/sandbox/test/inline_template_test.rb | 2 +- test/sandbox/test/integration_test.rb | 10 ++-- test/sandbox/test/rendering_test.rb | 22 ++++---- test/sandbox/test/translatable_test.rb | 2 +- test/test_helper.rb | 6 +- 22 files changed, 126 insertions(+), 128 deletions(-) delete mode 100644 lib/view_component/component_error.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d383c7a76..48b03f96c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -60,6 +60,10 @@ nav_order: 5 *Stephen Nelson* +* BREAKING: Rename internal methods to have `__vc_` prefix if they shouldn't be used by consumers. Make internal constants private. Make `Collection#components`, `Slotable#register_polymorphic_slot` private. Remove unused `ComponentError` class. + + *Joel Hawksley* + * `ViewComponentsSystemTestController` should not exist outside of test environment *Joel Hawksley* diff --git a/lib/view_component.rb b/lib/view_component.rb index 62c9cd2cb..1e1468ca9 100644 --- a/lib/view_component.rb +++ b/lib/view_component.rb @@ -10,7 +10,6 @@ module ViewComponent autoload :CaptureCompatibility autoload :Compiler autoload :CompileCache - autoload :ComponentError autoload :Config autoload :Deprecation autoload :InlineTemplate diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 66aa3b8db..9019a5d5a 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -40,9 +40,6 @@ def config include ViewComponent::Translatable include ViewComponent::WithContentHelper - RESERVED_PARAMETER = :content - VC_INTERNAL_DEFAULT_FORMAT = :html - # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers @@ -78,7 +75,7 @@ def set_original_view_context(view_context) # # @return [String] def render_in(view_context, &block) - self.class.compile(raise_errors: true) + self.class.__vc_compile(raise_errors: true) @view_context = view_context self.__vc_original_view_context ||= view_context @@ -504,19 +501,19 @@ def with_collection(collection, spacer_component: nil, **args) def inherited(child) # Compile so child will inherit compiled `call_*` template methods that # `compile` defines - compile + __vc_compile # Give the child its own personal #render_template_for to protect against the case when # eager loading is disabled and the parent component is rendered before the child. In # such a scenario, the parent will override ViewComponent::Base#render_template_for, # meaning it will not be called for any children and thus not compile their templates. - if !child.instance_methods(false).include?(:render_template_for) && !child.compiled? + if !child.instance_methods(false).include?(:render_template_for) && !child.__vc_compiled? child.class_eval <<~RUBY, __FILE__, __LINE__ + 1 def render_template_for(requested_details) # Force compilation here so the compiler always redefines render_template_for. # This is mostly a safeguard to prevent infinite recursion. - self.class.compile(raise_errors: true, force: true) - # .compile replaces this method; call the new one + self.class.__vc_compile(raise_errors: true, force: true) + # .__vc_compile replaces this method; call the new one render_template_for(requested_details) end RUBY @@ -556,22 +553,22 @@ def render_template_for(requested_details) end # @private - def compiled? - compiler.compiled? + def __vc_compiled? + __vc_compiler.compiled? end # @private - def ensure_compiled - compile unless compiled? + def __vc_ensure_compiled + __vc_compile unless __vc_compiled? end # @private - def compile(raise_errors: false, force: false) - compiler.compile(raise_errors: raise_errors, force: force) + def __vc_compile(raise_errors: false, force: false) + __vc_compiler.compile(raise_errors: raise_errors, force: force) end # @private - def compiler + def __vc_compiler @__vc_compiler ||= Compiler.new(self) end @@ -613,8 +610,8 @@ def strip_trailing_whitespace? # is accepted, as support for collection # rendering is optional. # @private - def validate_collection_parameter!(validate_default: false) - parameter = validate_default ? collection_parameter : provided_collection_parameter + def __vc_validate_collection_parameter!(validate_default: false) + parameter = validate_default ? __vc_collection_parameter : provided_collection_parameter return unless parameter return if initialize_parameter_names.include?(parameter) || splatted_keyword_argument_present? @@ -633,35 +630,35 @@ def validate_collection_parameter!(validate_default: false) # invalid parameters that could override the framework's # methods. # @private - def validate_initialization_parameters! - return unless initialize_parameter_names.include?(RESERVED_PARAMETER) + def __vc_validate_initialization_parameters! + return unless initialize_parameter_names.include?(:content) - raise ReservedParameterError.new(name, RESERVED_PARAMETER) + raise ReservedParameterError.new(name, :content) end # @private - def collection_parameter + def __vc_collection_parameter provided_collection_parameter || name && name.demodulize.underscore.chomp("_component").to_sym end # @private - def collection_counter_parameter - :"#{collection_parameter}_counter" + def __vc_collection_counter_parameter + :"#{__vc_collection_parameter}_counter" end # @private - def counter_argument_present? - initialize_parameter_names.include?(collection_counter_parameter) + def __vc_counter_argument_present? + initialize_parameter_names.include?(__vc_collection_counter_parameter) end # @private - def collection_iteration_parameter - :"#{collection_parameter}_iteration" + def __vc_collection_iteration_parameter + :"#{__vc_collection_parameter}_iteration" end # @private - def iteration_argument_present? - initialize_parameter_names.include?(collection_iteration_parameter) + def __vc_iteration_argument_present? + initialize_parameter_names.include?(__vc_collection_iteration_parameter) end private diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index ff1359e33..696fe601e 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -22,12 +22,18 @@ def render_in(view_context, &block) end.join(rendered_spacer(view_context)).html_safe end + def each(&block) + components.each(&block) + end + + private + def components return @components if defined? @components iterator = ActionView::PartialIteration.new(@collection.size) - component.validate_collection_parameter!(validate_default: true) + component.__vc_validate_collection_parameter!(validate_default: true) @components = @collection.map do |item| component.new(**component_options(item, iterator)).tap do |component| @@ -36,12 +42,6 @@ def components end end - def each(&block) - components.each(&block) - end - - private - def initialize(component, object, spacer_component, **options) @component = component @collection = collection_variable(object || []) @@ -58,9 +58,9 @@ def collection_variable(object) end def component_options(item, iterator) - item_options = {component.collection_parameter => item} - item_options[component.collection_counter_parameter] = iterator.index if component.counter_argument_present? - item_options[component.collection_iteration_parameter] = iterator.dup if component.iteration_argument_present? + item_options = {component.__vc_collection_parameter => item} + item_options[component.__vc_collection_counter_parameter] = iterator.index if component.__vc_counter_argument_present? + item_options[component.__vc_collection_iteration_parameter] = iterator.dup if component.__vc_iteration_argument_present? @options.merge(item_options) end diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 9bad61349..4d46eb2f3 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -8,7 +8,7 @@ class Compiler # * true (a blocking mode which ensures thread safety when redefining the `call` method for components, # default in Rails development and test mode) # * false(a non-blocking mode, default in Rails production mode) - class_attribute :development_mode, default: false + class_attribute :__vc_development_mode, default: false def initialize(component) @component = component @@ -30,8 +30,8 @@ def compile(raise_errors: false, force: false) gather_templates - if self.class.development_mode && @templates.any?(&:requires_compiled_superclass?) - @component.superclass.compile(raise_errors: raise_errors) + if self.class.__vc_development_mode && @templates.any?(&:requires_compiled_superclass?) + @component.superclass.__vc_compile(raise_errors: raise_errors) end if template_errors.present? @@ -42,14 +42,14 @@ def compile(raise_errors: false, force: false) end if raise_errors - @component.validate_initialization_parameters! - @component.validate_collection_parameter! + @component.__vc_validate_initialization_parameters! + @component.__vc_validate_collection_parameter! end define_render_template_for @component.register_default_slots - @component.build_i18n_backend + @component.__vc_build_i18n_backend CompileCache.register(@component) end diff --git a/lib/view_component/component_error.rb b/lib/view_component/component_error.rb deleted file mode 100644 index 039e83e16..000000000 --- a/lib/view_component/component_error.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - class ComponentError < StandardError - end -end diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index 56502f993..171ea46b9 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -68,12 +68,12 @@ class Engine < Rails::Engine # :nodoc: initializer "view_component.eager_load_actions" do ActiveSupport.on_load(:after_initialize) do - ViewComponent::Base.descendants.each(&:compile) if Rails.application.config.eager_load + ViewComponent::Base.descendants.each(&:__vc_compile) if Rails.application.config.eager_load end end initializer "compiler mode" do |_app| - ViewComponent::Compiler.development_mode = (Rails.env.development? || Rails.env.test?) + ViewComponent::Compiler.__vc_development_mode = (Rails.env.development? || Rails.env.test?) end config.after_initialize do |app| diff --git a/lib/view_component/inline_template.rb b/lib/view_component/inline_template.rb index 7795908ec..5095746ba 100644 --- a/lib/view_component/inline_template.rb +++ b/lib/view_component/inline_template.rb @@ -41,13 +41,13 @@ def inline_template @__vc_inline_template if defined?(@__vc_inline_template) end - def inline_template_language + def __vc_inline_template_language @__vc_inline_template_language if defined?(@__vc_inline_template_language) end def inherited(subclass) super - subclass.instance_variable_set(:@__vc_inline_template_language, inline_template_language) + subclass.instance_variable_set(:@__vc_inline_template_language, __vc_inline_template_language) end end end diff --git a/lib/view_component/preview.rb b/lib/view_component/preview.rb index 5e9846677..f2984a402 100644 --- a/lib/view_component/preview.rb +++ b/lib/view_component/preview.rb @@ -33,7 +33,7 @@ def render_with_template(template: nil, locals: {}) class << self # Returns all component preview classes. def all - load_previews + __vc_load_previews descendants end @@ -92,7 +92,8 @@ def preview_example_template_path(example) .sub(/\..*$/, "") end - def load_previews + # @private + def __vc_load_previews Array(preview_paths).each do |preview_path| Dir["#{preview_path}/**/*preview.rb"].sort.each { |file| require_dependency file } end diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index b22a6d6d5..913add67b 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -12,12 +12,10 @@ module Slotable singular: %i[content render].freeze, plural: %i[contents renders].freeze }.freeze + private_constant :RESERVED_NAMES - # Setup component slot state included do - # Hash of registered Slots - class_attribute :registered_slots - self.registered_slots = {} + class_attribute :registered_slots, default: {} end class_methods do @@ -213,6 +211,21 @@ def inherited(child) super end + # Called by the compiler, as instance methods are not defined when slots are first registered + def register_default_slots + registered_slots.each do |slot_name, config| + config[:default_method] = instance_methods.find { |method_name| method_name == :"default_#{slot_name}" } + + registered_slots[slot_name] = config + end + end + + private + + def register_slot(slot_name, **kwargs) + registered_slots[slot_name] = define_slot(slot_name, **kwargs) + end + def register_polymorphic_slot(slot_name, types, collection:) self::GeneratedSlotMethods.define_method(slot_name) do get_slot(slot_name) @@ -265,21 +278,6 @@ def register_polymorphic_slot(slot_name, types, collection:) } end - # Called by the compiler, as instance methods are not defined when slots are first registered - def register_default_slots - registered_slots.each do |slot_name, config| - config[:default_method] = instance_methods.find { |method_name| method_name == :"default_#{slot_name}" } - - registered_slots[slot_name] = config - end - end - - private - - def register_slot(slot_name, **kwargs) - registered_slots[slot_name] = define_slot(slot_name, **kwargs) - end - def define_slot(slot_name, collection:, callable:) slot = {collection: collection} return slot unless callable diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index b4313fe50..f3aa0346c 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -2,6 +2,9 @@ module ViewComponent class Template + DEFAULT_FORMAT = :html + private_constant :DEFAULT_FORMAT + DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true) attr_reader :details @@ -119,7 +122,7 @@ def inline_call? end def default_format? - format.nil? || format == ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT + format.nil? || format == DEFAULT_FORMAT end alias_method :html?, :default_format? @@ -145,7 +148,7 @@ def compiled_source this_source.rstrip! if @component.strip_trailing_whitespace? short_identifier = defined?(Rails.root) ? @path.sub("#{Rails.root}/", "") : @path - format = self.format || ViewComponent::Base::VC_INTERNAL_DEFAULT_FORMAT + format = self.format || DEFAULT_FORMAT type = ActionView::Template::Types[format] handler.call(DataWithSource.new(format:, identifier: @path, short_identifier:, type:), this_source) diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 541e28568..8a7257d68 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -9,19 +9,22 @@ module Translatable extend ActiveSupport::Concern HTML_SAFE_TRANSLATION_KEY = /(?:_|\b)html\z/ + private_constant :HTML_SAFE_TRANSLATION_KEY + TRANSLATION_EXTENSIONS = %w[yml yaml].freeze + private_constant :TRANSLATION_EXTENSIONS included do - class_attribute :i18n_backend, instance_writer: false, instance_predicate: false + class_attribute :__vc_i18n_backend, instance_writer: false, instance_predicate: false end class_methods do - def i18n_scope - @i18n_scope ||= virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".") + def __vc_i18n_scope + @__vc_i18n_scope ||= virtual_path.sub(%r{^/}, "").gsub(%r{/_?}, ".") end - def build_i18n_backend - return if compiled? + def __vc_build_i18n_backend + return if __vc_compiled? # We need to load the translations files from the ancestors so a component # can inherit translations from its parent and is able to overwrite them. @@ -32,31 +35,31 @@ def build_i18n_backend end # In development it will become nil if the translations file is removed - self.i18n_backend = if translation_files.any? + self.__vc_i18n_backend = if translation_files.any? I18nBackend.new( - i18n_scope: i18n_scope, + scope: __vc_i18n_scope, load_paths: translation_files ) end end - def i18n_key(key, scope = nil) + def __vc_i18n_key(key, scope = nil) scope = scope.join(".") if scope.is_a? Array key = key&.to_s unless key.is_a?(String) key = "#{scope}.#{key}" if scope - key = "#{i18n_scope}#{key}" if key.start_with?(".") + key = "#{__vc_i18n_scope}#{key}" if key.start_with?(".") key end def translate(key = nil, **options) return key.map { |k| translate(k, **options) } if key.is_a?(Array) - ensure_compiled + __vc_ensure_compiled locale = options.delete(:locale) || ::I18n.locale - key = i18n_key(key, options.delete(:scope)) + key = __vc_i18n_key(key, options.delete(:scope)) - i18n_backend.translate(locale, key, options) + __vc_i18n_backend.translate(locale, key, options) end alias_method :t, :translate @@ -65,8 +68,8 @@ def translate(key = nil, **options) class I18nBackend < ::I18n::Backend::Simple EMPTY_HASH = {}.freeze - def initialize(i18n_scope:, load_paths:) - @i18n_scope = i18n_scope.split(".").map(&:to_sym) + def initialize(scope:, load_paths:) + @__vc_i18n_scope = scope.split(".").map(&:to_sym) @load_paths = load_paths end @@ -76,7 +79,7 @@ def load_translations end def scope_data(data) - @i18n_scope.reverse_each do |part| + @__vc_i18n_scope.reverse_each do |part| data = {part => data} end data @@ -90,19 +93,19 @@ def store_translations(locale, data, options = EMPTY_HASH) def translate(key = nil, **options) raise ViewComponent::TranslateCalledBeforeRenderError if view_context.nil? - return super unless i18n_backend + return super unless __vc_i18n_backend return key.map { |k| translate(k, **options) } if key.is_a?(Array) locale = options.delete(:locale) || ::I18n.locale - key = self.class.i18n_key(key, options.delete(:scope)) + key = self.class.__vc_i18n_key(key, options.delete(:scope)) as_html = HTML_SAFE_TRANSLATION_KEY.match?(key) html_escape_translation_options!(options) if as_html - if key.start_with?(i18n_scope + ".") + if key.start_with?(__vc_i18n_scope + ".") translated = catch(:exception) do - i18n_backend.translate(locale, key, options) + __vc_i18n_backend.translate(locale, key, options) end # Fallback to the global translations @@ -118,9 +121,8 @@ def translate(key = nil, **options) end alias_method :t, :translate - # Exposes .i18n_scope as an instance method - def i18n_scope - self.class.i18n_scope + def __vc_i18n_scope + self.class.__vc_i18n_scope end private diff --git a/test/sandbox/spec/components/previews/preview_component_spec.rb b/test/sandbox/spec/components/previews/preview_component_spec.rb index 178eaeee6..b5b8a754b 100644 --- a/test/sandbox/spec/components/previews/preview_component_spec.rb +++ b/test/sandbox/spec/components/previews/preview_component_spec.rb @@ -1,6 +1,6 @@ describe PreviewComponent do before do - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end it "renders the preview" do @@ -12,7 +12,7 @@ describe "PreviewComponent" do before do - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end it "raises an error" do diff --git a/test/sandbox/test/components/described_class_test.rb b/test/sandbox/test/components/described_class_test.rb index 1629a6850..65689d512 100644 --- a/test/sandbox/test/components/described_class_test.rb +++ b/test/sandbox/test/components/described_class_test.rb @@ -4,7 +4,7 @@ class DescribedClassTest < ViewComponent::TestCase def setup - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end def described_class diff --git a/test/sandbox/test/components/my_component_test.rb b/test/sandbox/test/components/my_component_test.rb index e50a0c143..12910f847 100644 --- a/test/sandbox/test/components/my_component_test.rb +++ b/test/sandbox/test/components/my_component_test.rb @@ -4,7 +4,7 @@ class MyComponentTest < ViewComponent::TestCase def setup - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end def test_render_preview diff --git a/test/sandbox/test/components/nil_described_class_test.rb b/test/sandbox/test/components/nil_described_class_test.rb index 335290fe3..f423786ac 100644 --- a/test/sandbox/test/components/nil_described_class_test.rb +++ b/test/sandbox/test/components/nil_described_class_test.rb @@ -4,7 +4,7 @@ class NilDescribedClassTest < ViewComponent::TestCase def setup - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end def described_class diff --git a/test/sandbox/test/components/render_preview_test.rb b/test/sandbox/test/components/render_preview_test.rb index 2c7fc31e2..5cf7a211f 100644 --- a/test/sandbox/test/components/render_preview_test.rb +++ b/test/sandbox/test/components/render_preview_test.rb @@ -4,7 +4,7 @@ class RenderPreviewTest < ViewComponent::TestCase def setup - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end def test_render_preview_from_class diff --git a/test/sandbox/test/inline_template_test.rb b/test/sandbox/test/inline_template_test.rb index 986db8b6b..843b8cf3e 100644 --- a/test/sandbox/test/inline_template_test.rb +++ b/test/sandbox/test/inline_template_test.rb @@ -114,7 +114,7 @@ class InlineComponentDerivedFromComponentSupportingVariants < Level2Component end test "inherits template_language" do - assert_equal "slim", InheritedInlineSlimComponent.inline_template_language + assert_equal "slim", InheritedInlineSlimComponent.__vc_inline_template_language end test "subclassed erb works" do diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 4e41470c5..8d89e1632 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -5,7 +5,7 @@ class IntegrationTest < ActionDispatch::IntegrationTest def setup - ViewComponent::Preview.load_previews + ViewComponent::Preview.__vc_load_previews end def test_rendering_component_in_a_view @@ -110,7 +110,7 @@ def test_inherited_component_with_call_method_does_not_recompile_superclass assert_select "div", "hello world" assert_response :success - compile_method_lines = UncompilableComponent.method(:compile).source.split("\n") + compile_method_lines = UncompilableComponent.method(:__vc_compile).source.split("\n") compile_method_lines.insert(1, 'raise "this should not happen" if self.name == "UncompilableComponent"') UncompilableComponent.instance_eval compile_method_lines.join("\n") @@ -605,7 +605,7 @@ def test_sets_the_compiler_mode_in_production_mode Rails.env = "production".inquiry ViewComponent::Engine.initializers.find { |i| i.name == "compiler mode" }.run - assert_equal false, ViewComponent::Compiler.development_mode + assert_equal false, ViewComponent::Compiler.__vc_development_mode ensure Rails.env = old_env ViewComponent::Engine.initializers.find { |i| i.name == "compiler mode" }.run @@ -615,12 +615,12 @@ def test_sets_the_compiler_mode_in_production_mode def test_sets_the_compiler_mode_in_development_mode Rails.env.stub :development?, true do ViewComponent::Engine.initializers.find { |i| i.name == "compiler mode" }.run - assert_equal true, ViewComponent::Compiler.development_mode + assert_equal true, ViewComponent::Compiler.__vc_development_mode end Rails.env.stub :test?, true do ViewComponent::Engine.initializers.find { |i| i.name == "compiler mode" }.run - assert_equal true, ViewComponent::Compiler.development_mode + assert_equal true, ViewComponent::Compiler.__vc_development_mode end end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 0d8300690..3988847c8 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -13,7 +13,7 @@ def test_render_inline_allocations # Stabilize compilation status ahead of testing allocations to simulate rendering # performance with compiled component ViewComponent::CompileCache.cache.delete(MyComponent) - MyComponent.ensure_compiled + MyComponent.__vc_ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? {"3.5.0" => 79, "3.4.2" => 85, "3.3.7" => 86} : {"3.3.7" => 85, "3.2.8" => 84} @@ -97,14 +97,14 @@ def test_raises_error_when_with_content_is_called_without_any_values def test_render_without_template render_inline(InlineComponent.new) - assert_predicate InlineComponent, :compiled? + assert_predicate InlineComponent, :__vc_compiled? assert_selector("input[type='text'][name='name']") end def test_render_child_without_template render_inline(InlineChildComponent.new) - assert_predicate InlineChildComponent, :compiled? + assert_predicate InlineChildComponent, :__vc_compiled? assert_selector("input[type='text'][name='name']") end @@ -223,7 +223,7 @@ def test_renders_inline_variant_template_when_variant_template_is_not_present with_variant :inline_variant do render_inline(InlineVariantComponent.new) - assert_predicate InlineVariantComponent, :compiled? + assert_predicate InlineVariantComponent, :__vc_compiled? assert_selector("input[type='text'][name='inline_variant']") end end @@ -232,7 +232,7 @@ def test_renders_child_inline_variant_when_variant_template_is_not_present with_variant :inline_variant do render_inline(InlineVariantChildComponent.new) - assert_predicate InlineVariantChildComponent, :compiled? + assert_predicate InlineVariantChildComponent, :__vc_compiled? assert_selector("input[type='text'][name='inline_variant']") end end @@ -437,7 +437,7 @@ def test_compiles_unrendered_component # but that might have been thrown away if code-reloading is enabled skip unless Rails.application.config.cache_classes - assert UnreferencedComponent.compiled? + assert UnreferencedComponent.__vc_compiled? end def test_compiles_components_without_initializers @@ -445,7 +445,7 @@ def test_compiles_components_without_initializers # but that might have been thrown away if code-reloading is enabled skip unless Rails.application.config.cache_classes - assert MissingInitializerComponent.compiled? + assert MissingInitializerComponent.__vc_compiled? end def test_renders_when_initializer_is_not_defined @@ -733,7 +733,7 @@ def test_component_with_invalid_parameter_names with_new_cache do exception = assert_raises ViewComponent::ReservedParameterError do - InvalidParametersComponent.compile(raise_errors: true) + InvalidParametersComponent.__vc_compile(raise_errors: true) end assert_match(/InvalidParametersComponent initializer can't accept the parameter/, exception.message) @@ -744,7 +744,7 @@ def test_component_with_invalid_named_parameter_names with_new_cache do exception = assert_raises ViewComponent::ReservedParameterError do - InvalidNamedParametersComponent.compile(raise_errors: true) + InvalidNamedParametersComponent.__vc_compile(raise_errors: true) end assert_match( @@ -786,7 +786,7 @@ def test_inherited_component_overrides_inherits_template def test_inherited_inline_component_inherits_inline_method render_inline(InlineInheritedComponent.new) - assert_predicate InlineInheritedComponent, :compiled? + assert_predicate InlineInheritedComponent, :__vc_compiled? assert_selector("input[type='text'][name='name']") end @@ -975,7 +975,7 @@ def test_multiple_inline_renders_of_the_same_component def test_inherited_component_renders_when_lazy_loading # Simulate lazy loading by manually removing the classes in question. This will completely - # undo the changes made by self.class.compile and friends, forcing a compile the next time + # undo the changes made by self.class.__vc_compile and friends, forcing a compile the next time # #render_template_for is called. This shouldn't be necessary except in the test environment, # since eager loading is turned on here. Object.send(:remove_const, :MyComponent) if defined?(MyComponent) diff --git a/test/sandbox/test/translatable_test.rb b/test/sandbox/test/translatable_test.rb index 3d1b7129b..5dfc04ceb 100644 --- a/test/sandbox/test/translatable_test.rb +++ b/test/sandbox/test/translatable_test.rb @@ -95,7 +95,7 @@ def test_translate_uses_the_helper_when_no_sidecar_file_is_provided ) do assert_equal "MISSING", translate(".hello", default: "MISSING") assert_equal "Hello from Rails translations!", translate("hello") - assert_nil TranslatableComponent.i18n_backend + assert_nil TranslatableComponent.__vc_i18n_backend end ensure ViewComponent::CompileCache.invalidate_class!(TranslatableComponent) diff --git a/test/test_helper.rb b/test/test_helper.rb index 220d672ce..54f99c0b4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -174,11 +174,11 @@ def with_default_preview_layout(layout, &block) end def with_compiler_development_mode(mode) - previous_mode = ViewComponent::Compiler.development_mode - ViewComponent::Compiler.development_mode = mode + previous_mode = ViewComponent::Compiler.__vc_development_mode + ViewComponent::Compiler.__vc_development_mode = mode yield ensure - ViewComponent::Compiler.development_mode = previous_mode + ViewComponent::Compiler.__vc_development_mode = previous_mode end def capture_warnings(&block) From 1ee6f91f4975d1bf45deec58731c75a04cf33793 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 3 Apr 2025 13:52:24 -0600 Subject: [PATCH 034/158] Add note about v4 being an LTS release (#2260) * add notes about LTS status of v4 release * Update docs/CHANGELOG.md * Update docs/history.md --- docs/CHANGELOG.md | 4 ++++ docs/CONTRIBUTING.md | 2 ++ docs/history.md | 4 ++++ docs/index.md | 2 ++ 4 files changed, 12 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 48e0b7be8..35895819f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ nav_order: 6 ## 4.0.0 +Almost six years after releasing [v1.0.0](https://github.com/ViewComponent/view_component/releases/tag/v1.0.0), we are proud to ship ViewComponent 4. This release marks a shift towards a Long Term Support model for the project, having reached significant feature maturity. While contributions are always welcome, we are unlikely to accept further breaking changes or major feature additions. + +This release makes the following breaking changes: + * BREAKING: Remove `use_deprecated_instrumentation_name` configuration option. Events will always use `render.view_component` name. *Joel Hawksley* diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 37b8ab88a..98f880ae1 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,6 +8,8 @@ nav_order: 9 _ViewComponent is intended to be a safe, welcoming space for collaboration. By participating you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md)._ +_As of version 4, ViewComponent is considered feature-complete. While contributions are always welcome, we are unlikely to accept further breaking changes or significant feature additions._ + Hi there! We're thrilled that you'd like to contribute to ViewComponent. Your help is essential for keeping it great. If you have any substantial changes that you would like to make, please [open an issue](http://github.com/viewcomponent/view_component/issues/new) first to discuss them with us. diff --git a/docs/history.md b/docs/history.md index 25cf7da11..f4eb161a7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -214,3 +214,7 @@ The largest consequence of this change is that we'll need to deprecate the old s We propose that we make at least one release with the new API and no deprecation warning followed by another release that includes the deprecation warning. This will give teams some time to migrate before running into deprecation warnings. + +## May 2024: ViewComponent 4 + +ViewComponent 4 is released and the project is moved into Long-Term Support, recognizing the feature-completeness of the framework. diff --git a/docs/index.md b/docs/index.md index 930693295..af1110d71 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,8 @@ nav_order: 1 A framework for creating reusable, testable & encapsulated view components, built to integrate seamlessly with Ruby on Rails. +_As of version 4, ViewComponent is in Long-Term Support and generally considered feature-complete._ + ## What's a ViewComponent? ViewComponents are Ruby objects used to build markup. Think of them as an evolution of the presenter pattern, inspired by [React](https://reactjs.org/docs/react-component.html). From 2e1e220cd90f867f6533f8fb53211e93cf886442 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 4 Apr 2025 13:45:35 -0600 Subject: [PATCH 035/158] --inline generator option now generates inline template (#2261) * --inline generator option now generates inline template. Use --call to generate #call method. * lints * vale * vale * interpolate template engine into heredoc --- docs/CHANGELOG.md | 6 +++- docs/CONTRIBUTING.md | 2 +- docs/guide/generators.md | 16 ++++++++--- .../view_component/abstract_generator.rb | 2 +- .../component/component_generator.rb | 13 +++++++++ .../component/templates/component.rb.tt | 7 ++++- .../view_component/erb/erb_generator.rb | 1 + .../generators/component_generator_test.rb | 28 +++++++++++++++++-- .../test/generators/erb_generator_test.rb | 4 +-- .../test/generators/haml_generator_test.rb | 4 +-- .../test/generators/slim_generator_test.rb | 4 +-- .../generators/tailwindcss_generator_test.rb | 4 +-- 12 files changed, 72 insertions(+), 19 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 35895819f..9379b284b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,10 +12,14 @@ nav_order: 6 ## 4.0.0 -Almost six years after releasing [v1.0.0](https://github.com/ViewComponent/view_component/releases/tag/v1.0.0), we are proud to ship ViewComponent 4. This release marks a shift towards a Long Term Support model for the project, having reached significant feature maturity. While contributions are always welcome, we are unlikely to accept further breaking changes or major feature additions. +Almost six years after releasing [v1.0.0](https://github.com/ViewComponent/view_component/releases/tag/v1.0.0), we're proud to ship ViewComponent 4. This release marks a shift towards a Long Term Support model for the project, having reached significant feature maturity. While contributions are always welcome, we're unlikely to accept further breaking changes or major feature additions. This release makes the following breaking changes: +* BREAKING: `--inline` generator option now generates inline template. Use `--call` to generate `#call` method. + + *Joel Hawksley* + * BREAKING: Remove `use_deprecated_instrumentation_name` configuration option. Events will always use `render.view_component` name. *Joel Hawksley* diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 98f880ae1..9400e7355 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -8,7 +8,7 @@ nav_order: 9 _ViewComponent is intended to be a safe, welcoming space for collaboration. By participating you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md)._ -_As of version 4, ViewComponent is considered feature-complete. While contributions are always welcome, we are unlikely to accept further breaking changes or significant feature additions._ +_As of version 4, ViewComponent is considered feature-complete. While contributions are always welcome, we're unlikely to accept further breaking changes or significant feature additions._ Hi there! We're thrilled that you'd like to contribute to ViewComponent. Your help is essential for keeping it great. diff --git a/docs/guide/generators.md b/docs/guide/generators.md index 1a1ed80b1..1c578ecd1 100644 --- a/docs/guide/generators.md +++ b/docs/guide/generators.md @@ -153,10 +153,7 @@ bin/rails generate view_component:component Example title --sidecar To always generate in the sidecar directory, set `config.view_component.generate.sidecar = true`. -### Use [inline rendering](/guide/templates.html#inline) (no template file) - -Since 2.24.0 -{: .label } +### Use [inline template](/guide/templates.html#inline) (no template file) ```console bin/rails generate view_component:component Example title --inline @@ -167,6 +164,17 @@ bin/rails generate view_component:component Example title --inline invoke erb ``` +### Use [call method](/guide/templates.html#call) (no template file) + +```console +bin/rails generate view_component:component Example title --call + + create app/components/example_component.rb + invoke test_unit + create test/components/example_component_test.rb + invoke erb +``` + ### Specify the parent class Since 2.41.0 diff --git a/lib/generators/view_component/abstract_generator.rb b/lib/generators/view_component/abstract_generator.rb index 8159f4e64..28fe568ad 100644 --- a/lib/generators/view_component/abstract_generator.rb +++ b/lib/generators/view_component/abstract_generator.rb @@ -3,7 +3,7 @@ module ViewComponent module AbstractGenerator def copy_view_file - template "component.html.#{engine_name}", destination unless options["inline"] + template("component.html.#{engine_name}", destination) unless options["inline"] || options["call"] end private diff --git a/lib/generators/view_component/component/component_generator.rb b/lib/generators/view_component/component/component_generator.rb index dd2a37cca..9824a346f 100644 --- a/lib/generators/view_component/component/component_generator.rb +++ b/lib/generators/view_component/component/component_generator.rb @@ -12,6 +12,7 @@ class ComponentGenerator < Rails::Generators::NamedBase argument :attributes, type: :array, default: [], banner: "attribute" check_class_collision suffix: "Component" + class_option :call, type: :boolean, default: false class_option :inline, type: :boolean, default: false class_option :locale, type: :boolean, default: ViewComponent::Base.config.generate.locale class_option :parent, type: :string, desc: "The parent class for the generated component" @@ -45,6 +46,10 @@ def parent_class ViewComponent::Base.config.component_parent_class || default_parent_class end + def initialize_signature? + initialize_signature.present? + end + def initialize_signature return if attributes.blank? @@ -56,9 +61,17 @@ def initialize_body end def initialize_call_method_for_inline? + options["call"] + end + + def inline_template? options["inline"] end + def template_engine + options["template_engine"] + end + def default_parent_class defined?(ApplicationComponent) ? ApplicationComponent : ViewComponent::Base end diff --git a/lib/generators/view_component/component/templates/component.rb.tt b/lib/generators/view_component/component/templates/component.rb.tt index 0d70706cf..ce513c596 100644 --- a/lib/generators/view_component/component/templates/component.rb.tt +++ b/lib/generators/view_component/component/templates/component.rb.tt @@ -2,7 +2,12 @@ <% module_namespacing do -%> class <%= class_name %><%= options[:skip_suffix] ? "" : "Component" %> < <%= parent_class %> -<%- if initialize_signature -%> +<%- if inline_template? -%> + <%= template_engine %>_template <<~<%= template_engine.upcase %> +

Hello, World!

+ <%= template_engine.upcase %> +<%- end -%> +<%- if initialize_signature? -%> def initialize(<%= initialize_signature %>) <%= initialize_body %> end diff --git a/lib/generators/view_component/erb/erb_generator.rb b/lib/generators/view_component/erb/erb_generator.rb index 8b5ea988d..4ee2ee437 100644 --- a/lib/generators/view_component/erb/erb_generator.rb +++ b/lib/generators/view_component/erb/erb_generator.rb @@ -11,6 +11,7 @@ class ErbGenerator < Rails::Generators::NamedBase source_root File.expand_path("templates", __dir__) class_option :sidecar, type: :boolean, default: false class_option :inline, type: :boolean, default: false + class_option :call, type: :boolean, default: false class_option :stimulus, type: :boolean, default: false def engine_name diff --git a/test/sandbox/test/generators/component_generator_test.rb b/test/sandbox/test/generators/component_generator_test.rb index 1a6f9a144..5737ffdae 100644 --- a/test/sandbox/test/generators/component_generator_test.rb +++ b/test/sandbox/test/generators/component_generator_test.rb @@ -54,11 +54,22 @@ def test_component_with_arguments end end + def test_component_with_call + run_generator %w[user name --call] + + assert_file "app/components/user_component.rb" do |component| + assert_match(/def call/, component) + end + + assert_no_file "app/components/user_component.html.erb" + assert_no_file "component.html.erb" + end + def test_component_with_inline run_generator %w[user name --inline] assert_file "app/components/user_component.rb" do |component| - assert_match(/def call/, component) + assert_match(/erb_template/, component) end assert_no_file "app/components/user_component.html.erb" @@ -127,6 +138,17 @@ def test_invoking_slim_template_engine assert_file "app/components/user_component.html.slim" end + def test_invoking_slim_template_engine_inline + run_generator %w[user --inline --template-engine slim] + + assert_file "app/components/user_component.rb" do |component| + assert_match(/slim_template <<~SLIM/, component) + end + + assert_no_file "app/components/user_component.html.slim" + assert_no_file "component.html.erb" + end + def test_invoking_haml_template_engine run_generator %w[user --template-engine haml] @@ -194,7 +216,7 @@ def test_component_with_stimulus end def test_component_with_stimulus_and_inline - run_generator %w[user --stimulus --inline] + run_generator %w[user --stimulus --call] assert_file "app/components/user_component.rb" do |component| assert_match(/data: { controller: "user-component" }/, component) @@ -222,7 +244,7 @@ def test_component_with_stimulus_and_sidecar end def test_component_with_stimulus_and_sidecar_and_inline - run_generator %w[user --stimulus --sidecar --inline] + run_generator %w[user --stimulus --sidecar --call] assert_file "app/components/user_component.rb" do |component| assert_match(/data: { controller: "user-component--user-component" }/, component) diff --git a/test/sandbox/test/generators/erb_generator_test.rb b/test/sandbox/test/generators/erb_generator_test.rb index ec368c024..fcb79e486 100644 --- a/test/sandbox/test/generators/erb_generator_test.rb +++ b/test/sandbox/test/generators/erb_generator_test.rb @@ -40,8 +40,8 @@ def test_component_with_namespace_and_sidecar assert_file "app/components/admins/user_component/user_component.html.erb" end - def test_component_with_inline - run_generator %w[user name --inline] + def test_component_with_call + run_generator %w[user name --call] assert_no_file "app/components/user_component.html.erb" end diff --git a/test/sandbox/test/generators/haml_generator_test.rb b/test/sandbox/test/generators/haml_generator_test.rb index 1cb6b3901..b123364bc 100644 --- a/test/sandbox/test/generators/haml_generator_test.rb +++ b/test/sandbox/test/generators/haml_generator_test.rb @@ -40,8 +40,8 @@ def test_component_with_namespace_and_sidecar assert_file "app/components/admins/user_component/user_component.html.haml" end - def test_component_with_inline - run_generator %w[user name --inline] + def test_component_with_call + run_generator %w[user name --call] assert_no_file "app/components/user_component.html.haml" end diff --git a/test/sandbox/test/generators/slim_generator_test.rb b/test/sandbox/test/generators/slim_generator_test.rb index d03b85fad..bfc001771 100644 --- a/test/sandbox/test/generators/slim_generator_test.rb +++ b/test/sandbox/test/generators/slim_generator_test.rb @@ -48,8 +48,8 @@ def test_component_with_generate_sidecar end end - def test_component_with_inline - run_generator %w[user name --inline] + def test_component_with_call + run_generator %w[user name --call] assert_no_file "app/components/user_component.html.slim" end diff --git a/test/sandbox/test/generators/tailwindcss_generator_test.rb b/test/sandbox/test/generators/tailwindcss_generator_test.rb index c2a2c5b08..8a536bc90 100644 --- a/test/sandbox/test/generators/tailwindcss_generator_test.rb +++ b/test/sandbox/test/generators/tailwindcss_generator_test.rb @@ -40,8 +40,8 @@ def test_component_with_namespace_and_sidecar assert_file "app/components/admins/user_component/user_component.html.erb" end - def test_component_with_inline - run_generator %w[user name --inline] + def test_component_with_call + run_generator %w[user name --call] assert_no_file "app/components/user_component.html.erb" end From d9a60d576c5ef3397e694c319277b86ae5f5806f Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 4 Apr 2025 13:45:47 -0600 Subject: [PATCH 036/158] Update deprecation horizon and version # (#2263) * update deprecation horizon to be 5.0.0 * update version to 4.0.0.alpha1 --- Gemfile.lock | 2 +- lib/view_component/deprecation.rb | 2 +- lib/view_component/version.rb | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 91d7834a1..64b083010 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (3.22.0) + view_component (4.0.0.alpha1) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) diff --git a/lib/view_component/deprecation.rb b/lib/view_component/deprecation.rb index bec827c91..d311960cd 100644 --- a/lib/view_component/deprecation.rb +++ b/lib/view_component/deprecation.rb @@ -3,6 +3,6 @@ require "active_support/deprecation" module ViewComponent - DEPRECATION_HORIZON = "4.0.0" + DEPRECATION_HORIZON = "5.0.0" Deprecation = ActiveSupport::Deprecation.new(DEPRECATION_HORIZON, "ViewComponent") end diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index 6ec8823a3..84cf9d387 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -2,10 +2,10 @@ module ViewComponent module VERSION - MAJOR = 3 - MINOR = 22 + MAJOR = 4 + MINOR = 0 PATCH = 0 - PRE = nil + PRE = "alpha1" STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".") end From d3bdef02044381271cdb7cf056e324e1b42e750d Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 9 Apr 2025 07:42:50 -0600 Subject: [PATCH 037/158] Fix bug where request-aware helpers did not work outside of the request context. (#2270) --- app/views/test_mailer/test_asset_email.html.erb | 1 + docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 5 ++--- test/sandbox/app/components/asset_component.html.erb | 1 + test/sandbox/app/mailers/test_mailer.rb | 8 ++++++++ test/sandbox/test/mailer_test.rb | 11 +++++++++-- 6 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 app/views/test_mailer/test_asset_email.html.erb diff --git a/app/views/test_mailer/test_asset_email.html.erb b/app/views/test_mailer/test_asset_email.html.erb new file mode 100644 index 000000000..d93e535c3 --- /dev/null +++ b/app/views/test_mailer/test_asset_email.html.erb @@ -0,0 +1 @@ +<%= render AssetComponent.new %> diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9379b284b..e1196fec4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -72,6 +72,10 @@ This release makes the following breaking changes: *Joel Hawksley* +* Fix bug where request-aware helpers did not work outside of the request context. + + *Joel Hawksley*, *Stephen Nelson* + * `ViewComponentsSystemTestController` should not exist outside of test environment *Joel Hawksley* diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index b0dc80983..d4adbec3b 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -276,11 +276,10 @@ def request __vc_request end - # Enables consumers to override request/@request - # # @private def __vc_request - @__vc_request ||= controller.request + # The current request (if present, as mailers/jobs/etc do not have a request) + @__vc_request ||= controller.request if controller.respond_to?(:request) end # The content passed to the component instance as a block. diff --git a/test/sandbox/app/components/asset_component.html.erb b/test/sandbox/app/components/asset_component.html.erb index 252f1e94d..3cd5a0790 100644 --- a/test/sandbox/app/components/asset_component.html.erb +++ b/test/sandbox/app/components/asset_component.html.erb @@ -1 +1,2 @@
<%= asset_url("application.css") %>
+
<%= request %>
diff --git a/test/sandbox/app/mailers/test_mailer.rb b/test/sandbox/app/mailers/test_mailer.rb index 1446646d8..7824431cf 100644 --- a/test/sandbox/app/mailers/test_mailer.rb +++ b/test/sandbox/app/mailers/test_mailer.rb @@ -8,4 +8,12 @@ def test_email subject: "Testing ViewComponent in ActionMailer" ) end + + def test_asset_email + mail( + from: "no-reply@example.com", + to: "test@example.com", + subject: "Testing ViewComponent with Assets in ActionMailer" + ) + end end diff --git a/test/sandbox/test/mailer_test.rb b/test/sandbox/test/mailer_test.rb index cd5a5f4b1..29c43ba2f 100644 --- a/test/sandbox/test/mailer_test.rb +++ b/test/sandbox/test/mailer_test.rb @@ -2,8 +2,15 @@ require "test_helper" -class MailerTest < ViewComponent::TestCase +class MailerTest < ActionMailer::TestCase def test_rendering_component_in_an_action_mailer - assert_includes TestMailer.test_email.deliver_now.body.raw_source, "
Hello world!
" + result = TestMailer.test_email.deliver_now.body.to_s + assert_includes result, "
Hello world!
" + assert_includes result, "test_email.html.erb" + end + + def test_rendering_component_in_an_action_mailer_with_asset_component + result = TestMailer.test_asset_email.deliver_now.body.to_s + assert_includes result, "
/assets" end end From 82e4a2fa6e94abc114a2dd29ed27d8c4033d70c2 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 11 Apr 2025 10:42:24 -0600 Subject: [PATCH 038/158] 2269 system test class eager loading (#2277) * ViewComponentsSystemTestController should always create a class Resolves #2269 by returning an empty class outside test environments. * alternative approach --------- Co-authored-by: Stephen Nelson --- app/controllers/view_components_system_test_controller.rb | 5 ++--- docs/CHANGELOG.md | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/view_components_system_test_controller.rb b/app/controllers/view_components_system_test_controller.rb index 5e2f9e7f2..67677e85e 100644 --- a/app/controllers/view_components_system_test_controller.rb +++ b/app/controllers/view_components_system_test_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true - -if Rails.env.test? - class ViewComponentsSystemTestController < ActionController::Base # :nodoc: +class ViewComponentsSystemTestController < ActionController::Base # :nodoc: + if Rails.env.test? before_action :validate_file_path def self.temp_dir diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e1196fec4..6661fcfc0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -76,9 +76,9 @@ This release makes the following breaking changes: *Joel Hawksley*, *Stephen Nelson* -* `ViewComponentsSystemTestController` should not exist outside of test environment +* `ViewComponentsSystemTestController` should not be useable outside of test environment - *Joel Hawksley* + *Joel Hawksley*, *Stephen Nelson* * Remove unnecessary ENABLE_RELOADING test suite flag. From b88acf8c02e09d913a38f8a01b82d8f1af57713b Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 24 Apr 2025 11:03:56 -0600 Subject: [PATCH 039/158] allocations --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3988847c8..f3f00bf5a 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 79, "3.4.2" => 85, "3.3.7" => 86} : {"3.3.7" => 85, "3.2.8" => 84} + {"3.5.0" => 79, "3.4.3" => 85, "3.3.8" => 86} : {"3.3.8" => 85, "3.2.8" => 84} with_instrumentation_enabled_option(false) do assert_allocations(**allocations) do From d4a403ef76ab1cee34966f88a77e3a041398fad0 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 24 Apr 2025 11:23:40 -0600 Subject: [PATCH 040/158] bundle update --- .tool-versions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tool-versions b/.tool-versions index ae5ecdb2b..a72ead61f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -ruby 3.4.2 +ruby 3.4.3 From 4620e7dcedf7ed660e4931bf1e16b49ae0c6706b Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 24 Apr 2025 11:46:26 -0600 Subject: [PATCH 041/158] lint --- app/controllers/view_components_system_test_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/view_components_system_test_controller.rb b/app/controllers/view_components_system_test_controller.rb index 67677e85e..b9ad5f36a 100644 --- a/app/controllers/view_components_system_test_controller.rb +++ b/app/controllers/view_components_system_test_controller.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + class ViewComponentsSystemTestController < ActionController::Base # :nodoc: if Rails.env.test? before_action :validate_file_path From 3c311839c6db4240f40dc9354407a040b5b30557 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 25 Apr 2025 15:53:11 -0400 Subject: [PATCH 042/158] Add #current_template accessor and Template#path (#2283) Prior to the compiler changes in v4, we set @current_template to be the current instance of the component. Now, we set it to the current ViewComponent::Template. In the GitHub monolith, we reference @current_template when doing certain diagnostic operations. I added an accessor to make this less messy to use (and test) and exposed #path on template, which now allows for such diagnostic operations to reference the specific component template being rendered. --- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 1 + lib/view_component/template.rb | 2 +- .../app/components/current_template_component.html.erb | 1 + test/sandbox/app/components/current_template_component.rb | 4 ++++ test/sandbox/test/rendering_test.rb | 8 ++++++++ 6 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test/sandbox/app/components/current_template_component.html.erb create mode 100644 test/sandbox/app/components/current_template_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 395116f6d..399af6ffa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -136,6 +136,10 @@ This release makes the following breaking changes: *Joel Hawksley* +* Add `#current_template` accessor and `Template#path` for diagnostic usage. + + *Joel Hawksley* + ## 3.22.0 * Rewrite `ViewComponents at GitHub` documentation as more general `Best practices`. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index d4adbec3b..20d13c149 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -50,6 +50,7 @@ def config class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false attr_accessor :__vc_original_view_context + attr_reader :current_template # Components render in their own view context. Helpers and other functionality # require a reference to the original Rails view context, an instance of diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index f3aa0346c..c4d41f71c 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -7,7 +7,7 @@ class Template DataWithSource = Struct.new(:format, :identifier, :short_identifier, :type, keyword_init: true) - attr_reader :details + attr_reader :details, :path delegate :virtual_path, to: :@component delegate :format, :variant, to: :@details diff --git a/test/sandbox/app/components/current_template_component.html.erb b/test/sandbox/app/components/current_template_component.html.erb new file mode 100644 index 000000000..10d4b4e1a --- /dev/null +++ b/test/sandbox/app/components/current_template_component.html.erb @@ -0,0 +1 @@ +<%= current_template.path %> diff --git a/test/sandbox/app/components/current_template_component.rb b/test/sandbox/app/components/current_template_component.rb new file mode 100644 index 000000000..4978dafd3 --- /dev/null +++ b/test/sandbox/app/components/current_template_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class CurrentTemplateComponent < ViewComponent::Base +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index f3f00bf5a..28b42e8f3 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1270,4 +1270,12 @@ def test_render_anonymous_component_without_template render_inline(mock_component.new) end end + + def test_current_template + component = CurrentTemplateComponent.new + + render_inline(component) + + assert(rendered_content.include?("current_template_component.html.erb")) + end end From df1259501bd97b9bf3821ed5915aa4eba0319aae Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 25 Apr 2025 13:54:46 -0600 Subject: [PATCH 043/158] tag for alpha2 --- docs/CHANGELOG.md | 12 +++++++----- lib/view_component/version.rb | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 399af6ffa..9b5648801 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,13 @@ nav_order: 6 ## main -## 4.0.0 +## 4.0.0.alpha2 + +* Add `#current_template` accessor and `Template#path` for diagnostic usage. + + *Joel Hawksley* + +## 4.0.0.alpha1 Almost six years after releasing [v1.0.0](https://github.com/ViewComponent/view_component/releases/tag/v1.0.0), we're proud to ship ViewComponent 4. This release marks a shift towards a Long Term Support model for the project, having reached significant feature maturity. While contributions are always welcome, we're unlikely to accept further breaking changes or major feature additions. @@ -136,10 +142,6 @@ This release makes the following breaking changes: *Joel Hawksley* -* Add `#current_template` accessor and `Template#path` for diagnostic usage. - - *Joel Hawksley* - ## 3.22.0 * Rewrite `ViewComponents at GitHub` documentation as more general `Best practices`. diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index 84cf9d387..c1dfed9b8 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -5,7 +5,7 @@ module VERSION MAJOR = 4 MINOR = 0 PATCH = 0 - PRE = "alpha1" + PRE = "alpha2" STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".") end From f3090b6e093a467b1c02c14ac86f6c22cc723a4c Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 30 Apr 2025 12:40:42 -0600 Subject: [PATCH 044/158] run against ruby head on v4 branch (#2289) * run against ruby head on v4 branch * loosen allocations check --- .github/workflows/ci.yml | 10 ++-------- Gemfile.lock | 2 +- test/sandbox/test/rendering_test.rb | 2 +- test/test_helper.rb | 2 +- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81119827d..013cb338e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,18 +53,12 @@ jobs: - ruby_version: "3.4" rails_version: "8.0" mode: "capture_patch_enabled" - - ruby_version: "3.4" + - ruby_version: "head" rails_version: "main" mode: "capture_patch_disabled" - - ruby_version: "3.4" + - ruby_version: "head" rails_version: "main" mode: "capture_patch_enabled" - - ruby_version: "3.5" - rails_version: "8.0" - mode: "capture_patch_disabled" - - ruby_version: "3.5" - rails_version: "8.0" - mode: "capture_patch_enabled" env: BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails_version }}.gemfile steps: diff --git a/Gemfile.lock b/Gemfile.lock index f503fd9ce..d6926919d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (4.0.0.alpha1) + view_component (4.0.0.alpha2) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 28b42e8f3..3044a1aa3 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5.0" => 79, "3.4.3" => 85, "3.3.8" => 86} : {"3.3.8" => 85, "3.2.8" => 84} + {"3.5" => 79, "3.4" => 85, "3.3" => 86} : {"3.3" => 85, "3.2" => 84} with_instrumentation_enabled_option(false) do assert_allocations(**allocations) do diff --git a/test/test_helper.rb b/test/test_helper.rb index 54f99c0b4..2a4ef7077 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -192,7 +192,7 @@ def capture_warnings(&block) def assert_allocations(count_map, &block) trace = AllocationStats.trace(&block) total = trace.allocations.all.size - count = count_map[RUBY_VERSION] + count = count_map[RUBY_VERSION.split(".").first(2).join(".")] assert_equal count, total, "Expected #{count} allocations, got #{total} allocations for Ruby #{RUBY_VERSION}" end From c145dc88c8263d6728e6f09963f651cad7620142 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 30 Apr 2025 14:17:39 -0600 Subject: [PATCH 045/158] Remove dependency on ActionView::Base, eliminating the need for capture compatibility patch. (#2287) * Remove dependency on ActionView::Base, eliminating the need for capture compatibility patch. Co-authored-by: Cameron Dutro * Use later version of Rails 7.1 for PVC integration tests * docs updates --------- Co-authored-by: Cameron Dutro --- .github/workflows/ci.yml | 27 +---------- docs/CHANGELOG.md | 4 ++ docs/api.md | 7 --- docs/known_issues.md | 40 ---------------- lib/view_component/base.rb | 46 +++++++++++++++---- lib/view_component/capture_compatibility.rb | 44 ------------------ lib/view_component/collection.rb | 8 ---- lib/view_component/config.rb | 10 +--- lib/view_component/engine.rb | 39 +++++++++++++--- lib/view_component/slot.rb | 10 +--- lib/view_component/template.rb | 3 +- lib/view_component/translatable.rb | 6 +-- .../components/renders_non_component.html.erb | 1 - .../app/components/renders_non_component.rb | 20 -------- test/sandbox/config/environments/test.rb | 1 - .../test/action_view_compatibility_test.rb | 19 -------- test/sandbox/test/config_test.rb | 8 ---- test/sandbox/test/integration_test.rb | 8 +--- test/sandbox/test/rendering_test.rb | 16 +------ 19 files changed, 85 insertions(+), 232 deletions(-) delete mode 100644 lib/view_component/capture_compatibility.rb delete mode 100644 test/sandbox/app/components/renders_non_component.html.erb delete mode 100644 test/sandbox/app/components/renders_non_component.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 013cb338e..cffe7c8b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: bundle exec appraisal rails-8.0 rake partial_benchmark bundle exec appraisal rails-8.0 rake translatable_benchmark test: - name: test (${{ matrix.rails_version }}, ${{ matrix.ruby_version }}, ${{ matrix.mode }}) + name: test (Rails ${{ matrix.rails_version }}, Ruby ${{ matrix.ruby_version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -31,34 +31,12 @@ jobs: include: - ruby_version: "3.2" rails_version: "7.1" - mode: "capture_patch_enabled" - - ruby_version: "3.2" - rails_version: "7.1" - mode: "capture_patch_disabled" - - ruby_version: "3.3" - rails_version: "7.2" - mode: "capture_patch_disabled" - ruby_version: "3.3" rails_version: "7.2" - mode: "capture_patch_enabled" - - ruby_version: "3.3" - rails_version: "8.0" - mode: "capture_patch_disabled" - - ruby_version: "3.3" - rails_version: "8.0" - mode: "capture_patch_enabled" - - ruby_version: "3.4" - rails_version: "8.0" - mode: "capture_patch_disabled" - ruby_version: "3.4" rails_version: "8.0" - mode: "capture_patch_enabled" - - ruby_version: "head" - rails_version: "main" - mode: "capture_patch_disabled" - ruby_version: "head" rails_version: "main" - mode: "capture_patch_enabled" env: BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.rails_version }}.gemfile steps: @@ -76,7 +54,6 @@ jobs: RAISE_ON_WARNING: 1 RAILS_VERSION: ${{ matrix.rails_version }} RUBY_VERSION: ${{ matrix.ruby_version }} - CAPTURE_PATCH_ENABLED: ${{ matrix.mode == 'capture_patch_enabled' && 'true' || 'false' }} - name: Upload coverage results uses: actions/upload-artifact@v4.4.0 if: always() @@ -114,7 +91,7 @@ jobs: bundle && bundle exec rake env: VIEW_COMPONENT_PATH: ../view_component - RAILS_VERSION: '7.1.1' + RAILS_VERSION: '7.1.5' PARALLEL_WORKERS: '1' coverage: needs: test diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d2d1242a3..398be6855 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* BREAKING: Remove dependency on `ActionView::Base`, eliminating the need for capture compatibility patch. + + *Cameron Dutro* + ## 4.0.0.alpha2 * Add `#current_template` accessor and `Template#path` for diagnostic usage. diff --git a/docs/api.md b/docs/api.md index 2a4676579..a4b081b0f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -142,13 +142,6 @@ so helpers, etc work as expected. ## Configuration -### `.capture_compatibility_patch_enabled` - -Enables the experimental capture compatibility patch that makes ViewComponent -compatible with forms, capture, and other built-ins. -previews. -Defaults to `false`. - ### `.component_parent_class` The parent class from which generated components will inherit. diff --git a/docs/known_issues.md b/docs/known_issues.md index 24dca12c7..dafbc9fdb 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -27,46 +27,6 @@ en: It would be lovely if we could support rendering ViewComponents in Jekyll, as it would enable the reuse of ViewComponents across static and dynamic (Rails-based) sites. -## Issues resolved by the optional capture compatibility patch - -If you're experiencing issues with duplicated content or malformed HTML output (such as using `concat` in a helper), the capture compatibility patch may resolve these. - -[Set `config.view_component.capture_compatibility_patch_enabled` to `true`](https://viewcomponent.org/api.html#capture_compatibility_patch_enabled) to resolve these issues. - -These issues arise because the related features/methods keep a reference to the -primary `ActionView::Base` instance, which has its own `@output_buffer`. When -`#capture` is called on the original `ActionView::Base` instance while -evaluating a block from a ViewComponent, the `@output_buffer` is overridden in -the `ActionView::Base` instance, and *not* the component. This results in a -double render due to `#capture` implementation details. - -To resolve the issue, we override `#capture` so that we can delegate the -`capture` logic to the ViewComponent that created the block. - -### turbo_frame_tag double rendering or scrambled HTML structure - -When using `turbo_frame_tag` inside a ViewComponent, the template may be rendered twice. See [https://github.com/github/view_component/issues/1099](https://github.com/github/view_component/issues/1099). - -As a workaround, use `tag.turbo_frame` instead of `turbo_frame_tag`. - -Note: For the same functionality as `turbo_frame_tag(my_model)`, use `tag.turbo_frame(id: dom_id(my_model))`. - -### Compatibility with Rails form helpers - -ViewComponent [isn't compatible](https://github.com/viewcomponent/view_component/issues/241) with `form_for` helpers by default. - -Passing a form object (often `f`) to a ViewComponent works for simple cases like `f.text_field :name`. Content may be ill-ordered or duplicated in complex cases, such as passing blocks to form helpers or when nesting components. - -Some workarounds include: - -- Experimental: Enable the capture compatibility patch with `config.view_component.capture_compatibility_patch_enabled = true`. -- Render an entire form within a single ViewComponent. -- Render a [partial](https://guides.rubyonrails.org/layouts_and_rendering.html#using-partials) within the ViewComponent which includes the form. -- Use a [custom `FormBuilder`](https://guides.rubyonrails.org/form_helpers.html#customizing-form-builders) to create reusable form components: - - Using FormBuilder with [Action View helpers](https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html). - - Using a FormBuilder overriding all field helpers to render a ViewComponent so each field can be customized individually (for example, [view_component-form](https://github.com/pantographe/view_component-form)). - - Using a lightweight re-implementation of ViewComponent. For example, [Primer ViewComponents](https://github.com/primer/view_components) implemented [`ActsAsComponent`](https://github.com/primer/view_components/blob/main/lib/primer/forms/acts_as_component.rb) which is used in the context of `FormBuilder`. - ## Forms don't use the default `FormBuilder` Calls to form helpers such as `form_with` in ViewComponents [don't use the default form builder](https://github.com/viewcomponent/view_component/pull/1090#issue-753331927). This is by design, as it allows global state to change the rendered output of a component. Instead, consider passing a form builder into form helpers via the `builder` argument: diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 1cf17be25..34edaba7b 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -18,8 +18,21 @@ require "view_component/with_content_helper" require "view_component/use_helpers" +module ActionView + class OutputBuffer + def with_buffer(buf = nil) + new_buffer = buf || +"" + old_buffer, @raw_buffer = @raw_buffer, new_buffer + yield + new_buffer + ensure + @raw_buffer = old_buffer + end + end +end + module ViewComponent - class Base < ActionView::Base + class Base class << self delegate(*ViewComponent::Config.defaults.keys, to: :config) @@ -35,6 +48,10 @@ def config end end + include ActionView::Helpers + include ERB::Escape + include ActiveSupport::CoreExt::ERBUtil + include ViewComponent::InlineTemplate include ViewComponent::UseHelpers include ViewComponent::Slotable @@ -45,6 +62,9 @@ def config # For CSRF authenticity tokens in forms delegate :form_authenticity_token, :protect_against_forgery?, :config, to: :helpers + # HTML construction methods + delegate :output_buffer, :lookup_context, :view_renderer, :view_flow, to: :helpers + # For Content Security Policy nonces delegate :content_security_policy_nonce, to: :helpers @@ -61,7 +81,7 @@ def config # @param view_context [ActionView::Base] The original view context. # @return [void] def set_original_view_context(view_context) - self.__vc_original_view_context = view_context + # noop end using RequestDetails @@ -80,7 +100,7 @@ def render_in(view_context, &block) @view_context = view_context self.__vc_original_view_context ||= view_context - @output_buffer = ActionView::OutputBuffer.new + @output_buffer = view_context.output_buffer @lookup_context ||= view_context.lookup_context @@ -107,14 +127,20 @@ def render_in(view_context, &block) before_render if render? - rendered_template = render_template_for(@__vc_requested_details).to_s + value = nil + + @output_buffer.with_buffer do + rendered_template = render_template_for(@__vc_requested_details).to_s - # Avoid allocating new string when output_preamble and output_postamble are blank - if output_preamble.blank? && output_postamble.blank? - rendered_template - else - safe_output_preamble + rendered_template + safe_output_postamble + # Avoid allocating new string when output_preamble and output_postamble are blank + value = if output_preamble.blank? && output_postamble.blank? + rendered_template + else + safe_output_preamble + rendered_template + safe_output_postamble + end end + + value else "" end @@ -206,7 +232,7 @@ def initialize(*) def render(options = {}, args = {}, &block) if options.respond_to?(:set_original_view_context) options.set_original_view_context(self.__vc_original_view_context) - super + @view_context.render(options, args, &block) else __vc_original_view_context.render(options, args, &block) end diff --git a/lib/view_component/capture_compatibility.rb b/lib/view_component/capture_compatibility.rb deleted file mode 100644 index 15477b3a3..000000000 --- a/lib/view_component/capture_compatibility.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent - # CaptureCompatibility is a module that patches #capture to fix issues - # related to ViewComponent and functionality that relies on `capture` - # like forms, capture itself, turbo frames, etc. - # - # This underlying incompatibility with ViewComponent and capture is - # that several features like forms keep a reference to the primary - # `ActionView::Base` instance which has its own @output_buffer. When - # `#capture` is called on the original `ActionView::Base` instance while - # evaluating a block from a ViewComponent the @output_buffer is overridden - # in the ActionView::Base instance, and *not* the component. This results - # in a double render due to `#capture` implementation details. - # - # To resolve the issue, we override `#capture` so that we can delegate - # the `capture` logic to the ViewComponent that created the block. - module CaptureCompatibility - def self.included(base) - return if base < InstanceMethods - - base.class_eval do - alias_method :original_capture, :capture - end - - base.prepend(InstanceMethods) - end - - module InstanceMethods - def capture(*args, &block) - # Handle blocks that originate from C code and raise, such as `&:method` - return original_capture(*args, &block) if block.source_location.nil? - - block_context = block.binding.receiver - - if block_context != self && block_context.class < ActionView::Base - block_context.original_capture(*args, &block) - else - original_capture(*args, &block) - end - end - end - end -end diff --git a/lib/view_component/collection.rb b/lib/view_component/collection.rb index 696fe601e..d0e9234d5 100644 --- a/lib/view_component/collection.rb +++ b/lib/view_component/collection.rb @@ -9,15 +9,8 @@ class Collection delegate :size, to: :@collection - attr_accessor :__vc_original_view_context - - def set_original_view_context(view_context) - self.__vc_original_view_context = view_context - end - def render_in(view_context, &block) components.map do |component| - component.set_original_view_context(__vc_original_view_context) component.render_in(view_context, &block) end.join(rendered_spacer(view_context)).html_safe end @@ -67,7 +60,6 @@ def component_options(item, iterator) def rendered_spacer(view_context) if @spacer_component - @spacer_component.set_original_view_context(__vc_original_view_context) @spacer_component.render_in(view_context) else "" diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index 9a65a746a..b427ac50a 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -21,8 +21,7 @@ def defaults show_previews: Rails.env.development? || Rails.env.test?, preview_paths: default_preview_paths, test_controller: "ApplicationController", - default_preview_layout: nil, - capture_compatibility_patch_enabled: false + default_preview_layout: nil }) end @@ -145,13 +144,6 @@ def defaults # previews. # Defaults to `nil`. If this is falsy, `"component_preview"` is used. - # @!attribute capture_compatibility_patch_enabled - # @return [Boolean] - # Enables the experimental capture compatibility patch that makes ViewComponent - # compatible with forms, capture, and other built-ins. - # previews. - # Defaults to `false`. - def default_preview_paths (default_rails_preview_paths + default_rails_engines_preview_paths).uniq end diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index 171ea46b9..a0f1a4664 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -51,12 +51,6 @@ class Engine < Rails::Engine # :nodoc: end end - initializer "view_component.enable_capture_patch" do |app| - ActiveSupport.on_load(:view_component) do - ActionView::Base.include(ViewComponent::CaptureCompatibility) if app.config.view_component.capture_compatibility_patch_enabled - end - end - initializer "view_component.set_autoload_paths" do |app| options = app.config.view_component @@ -66,6 +60,39 @@ class Engine < Rails::Engine # :nodoc: end end + initializer "view_component.propshaft_support" do |_app| + ActiveSupport.on_load(:view_component) do + if defined?(Propshaft) + include Propshaft::Helper + end + end + end + + config.after_initialize do |app| + ActiveSupport.on_load(:view_component) do + if defined?(Sprockets::Rails) + include Sprockets::Rails::Helper + + # Copy relevant config to VC context + # See: https://github.com/rails/sprockets-rails/blob/266ec49f3c7c96018dd75f9dc4f9b62fe3f7eecf/lib/sprockets/railtie.rb#L245 + self.debug_assets = app.config.assets.debug + self.digest_assets = app.config.assets.digest + self.assets_prefix = app.config.assets.prefix + self.assets_precompile = app.config.assets.precompile + + self.assets_environment = app.assets + self.assets_manifest = app.assets_manifest + + self.resolve_assets_with = app.config.assets.resolve_with + + self.check_precompiled_asset = app.config.assets.check_precompiled_asset + self.unknown_asset_fallback = app.config.assets.unknown_asset_fallback + # Expose the app precompiled asset check to the view + self.precompiled_asset_checker = ->(logical_path) { app.asset_precompiled? logical_path } + end + end + end + initializer "view_component.eager_load_actions" do ActiveSupport.on_load(:after_initialize) do ViewComponent::Base.descendants.each(&:__vc_compile) if Rails.application.config.eager_load diff --git a/lib/view_component/slot.rb b/lib/view_component/slot.rb index 4a52e34ae..d5c745ec9 100644 --- a/lib/view_component/slot.rb +++ b/lib/view_component/slot.rb @@ -58,15 +58,7 @@ def to_s if defined?(@__vc_content_block) # render_in is faster than `parent.render` @__vc_component_instance.render_in(view_context) do |*args| - return @__vc_content_block.call(*args) if @__vc_content_block&.source_location.nil? - - block_context = @__vc_content_block.binding.receiver - - if block_context.class < ActionView::Base - block_context.capture(*args, &@__vc_content_block) - else - @__vc_content_block.call(*args) - end + @__vc_content_block.call(*args) end else @__vc_component_instance.render_in(view_context) diff --git a/lib/view_component/template.rb b/lib/view_component/template.rb index c4d41f71c..d80c307f2 100644 --- a/lib/view_component/template.rb +++ b/lib/view_component/template.rb @@ -98,8 +98,9 @@ def compile_to_component @component.silence_redefinition_of_method(call_method_name) # rubocop:disable Style/EvalWithLocation - @component.class_eval <<~RUBY, @path, @lineno + @component.class_eval <<~RUBY, @path, @lineno - 1 def #{call_method_name} + @view_context.instance_variable_set(:@virtual_path, virtual_path) #{compiled_source} end RUBY diff --git a/lib/view_component/translatable.rb b/lib/view_component/translatable.rb index 8a7257d68..034c8b52f 100644 --- a/lib/view_component/translatable.rb +++ b/lib/view_component/translatable.rb @@ -93,7 +93,7 @@ def store_translations(locale, data, options = EMPTY_HASH) def translate(key = nil, **options) raise ViewComponent::TranslateCalledBeforeRenderError if view_context.nil? - return super unless __vc_i18n_backend + return @view_context.translate(key, **options) unless __vc_i18n_backend return key.map { |k| translate(k, **options) } if key.is_a?(Array) locale = options.delete(:locale) || ::I18n.locale @@ -110,13 +110,13 @@ def translate(key = nil, **options) # Fallback to the global translations if translated.is_a? ::I18n::MissingTranslation - return super(key, locale: locale, **options) + return @view_context.translate(key, locale: locale, **options) end translated = html_safe_translation(translated) if as_html translated else - super(key, locale: locale, **options) + @view_context.translate(key, locale: locale, **options) end end alias_method :t, :translate diff --git a/test/sandbox/app/components/renders_non_component.html.erb b/test/sandbox/app/components/renders_non_component.html.erb deleted file mode 100644 index a5a7b1a57..000000000 --- a/test/sandbox/app/components/renders_non_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render(@not_a_component) %> diff --git a/test/sandbox/app/components/renders_non_component.rb b/test/sandbox/app/components/renders_non_component.rb deleted file mode 100644 index 639d1b5af..000000000 --- a/test/sandbox/app/components/renders_non_component.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class RendersNonComponent < ViewComponent::Base - class NotAComponent - attr_reader :render_in_view_context, :original_view_context - - def render_in(view_context) - @render_in_view_context = view_context - "I'm not a component".html_safe - end - - def set_original_view_context(view_context) - @original_view_context = view_context - end - end - - def initialize(not_a_component:) - @not_a_component = not_a_component - end -end diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index d74f220fd..926f6e8ff 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -32,7 +32,6 @@ config.view_component.instrumentation_enabled = true config.view_component.preview_paths << "#{Rails.root}/lib/component_previews" config.view_component.test_controller = "IntegrationExamplesController" - config.view_component.capture_compatibility_patch_enabled = ENV["CAPTURE_PATCH_ENABLED"] == "true" # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the diff --git a/test/sandbox/test/action_view_compatibility_test.rb b/test/sandbox/test/action_view_compatibility_test.rb index 26d040614..625c6c6c6 100644 --- a/test/sandbox/test/action_view_compatibility_test.rb +++ b/test/sandbox/test/action_view_compatibility_test.rb @@ -4,8 +4,6 @@ class ViewComponent::ActionViewCompatibilityTest < ViewComponent::TestCase def test_renders_form_for_labels_with_block_correctly - skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled - render_inline(FormForComponent.new) assert_selector("form > div > label > input") @@ -13,8 +11,6 @@ def test_renders_form_for_labels_with_block_correctly end def test_renders_form_with_labels_with_block_correctly - skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled - render_inline(FormWithComponent.new) assert_selector("form > div > label > input") @@ -22,8 +18,6 @@ def test_renders_form_with_labels_with_block_correctly end def test_form_without_compatibility_does_not_raise - skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled - render_inline(IncompatibleFormComponent.new) # Bad selector should be present, at least until fixed upstream or included by default @@ -31,8 +25,6 @@ def test_form_without_compatibility_does_not_raise end def test_form_with_partial_render_works - skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled - render_inline(FormPartialComponent.new) # Bad selector should be present, at least until fixed upstream or included by default @@ -40,18 +32,7 @@ def test_form_with_partial_render_works end def test_helper_with_content_tag - skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled - render_inline(ContentTagComponent.new) assert_selector("div > p") end - - def test_including_compat_module_twice_does_not_blow_the_stack - skip unless Rails.application.config.view_component.capture_compatibility_patch_enabled - - ActionView::Base.include(ViewComponent::CaptureCompatibility) - render_inline(FormForComponent.new) - assert_selector("form > div > label > input") - refute_selector("form > div > input") - end end diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index a58952117..d73b2d368 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -36,13 +36,5 @@ def test_all_methods_are_documented assert configuration_methods_to_document.map(&:docstring).all?(&:present?), "Configuration options are missing docstrings." end - - def test_compatibility_module_included - if ENV["CAPTURE_PATCH_ENABLED"] == "true" - assert ActionView::Base < ViewComponent::CaptureCompatibility - else - refute ActionView::Base < ViewComponent::CaptureCompatibility - end - end end end diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 8d89e1632..ca4e8c129 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -512,7 +512,6 @@ def test_does_not_render_additional_newline_with_render_in end # This test documents a bug that reports an incompatibility with the turbo-rails gem's `turbo_stream` helper. - # This helper may work if the `capture_compatibility_patch` is enabled. # Prefer `tag.turbo_stream` instead if you do not have the patch enabled already. def test_render_component_in_turbo_stream without_template_annotations do @@ -520,11 +519,8 @@ def test_render_component_in_turbo_stream expected_response_body = <<~TURBOSTREAM TURBOSTREAM - if ViewComponent::Base.config.capture_compatibility_patch_enabled - assert_equal expected_response_body, response.body - else - assert_not_equal expected_response_body, response.body - end + + assert_equal expected_response_body, response.body end end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3044a1aa3..3b30bef29 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled allocations = (Rails.version.to_f >= 8.0) ? - {"3.5" => 79, "3.4" => 85, "3.3" => 86} : {"3.3" => 85, "3.2" => 84} + {"3.5" => 78, "3.4" => 84, "3.3" => 86} : {"3.3" => 84, "3.2" => 83} with_instrumentation_enabled_option(false) do assert_allocations(**allocations) do @@ -1055,20 +1055,6 @@ def test_component_renders_without_trailing_whitespace refute @rendered_content =~ /\s+\z/, "Rendered component contains trailing whitespace" end - def test_renders_objects_in_component_view_context - not_a_component = RendersNonComponent::NotAComponent.new - component = RendersNonComponent.new(not_a_component: not_a_component) - - render_inline(component) - - assert_selector "span", text: "I'm not a component" - - assert( - not_a_component.render_in_view_context == component, - "Component-like object was not rendered in the parent component's view context" - ) - end - def test_renders_nested_collection items = %w[foo bar baz boo] render_inline(NestedCollectionWrapperComponent.new(items: items)) From 038d49cd951599b74393b51f68ced990caf23e81 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 30 Apr 2025 14:23:29 -0600 Subject: [PATCH 046/158] release 4.0.0.alpha3 --- Gemfile.lock | 24 ++++++++++++------------ docs/CHANGELOG.md | 2 ++ lib/view_component/version.rb | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d6926919d..cb826fcd6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (4.0.0.alpha2) + view_component (4.0.0.alpha3) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) @@ -178,21 +178,21 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.6-aarch64-linux-gnu) + nokogiri (1.18.8-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-aarch64-linux-musl) + nokogiri (1.18.8-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.6-arm-linux-gnu) + nokogiri (1.18.8-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-arm-linux-musl) + nokogiri (1.18.8-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.6-arm64-darwin) + nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.6-x86_64-darwin) + nokogiri (1.18.8-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.6-x86_64-linux-gnu) + nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.6-x86_64-linux-musl) + nokogiri (1.18.8-x86_64-linux-musl) racc (~> 1.4) parallel (1.27.0) parser (3.3.8.0) @@ -210,7 +210,7 @@ GEM psych (5.2.3) date stringio - public_suffix (6.0.1) + public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) @@ -294,7 +294,7 @@ GEM rubocop-ast (1.44.1) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-md (2.0.0) + rubocop-md (2.0.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) rubocop-performance (1.25.0) @@ -304,7 +304,7 @@ GEM ruby-progressbar (1.13.0) rubyzip (2.4.1) securerandom (0.4.1) - selenium-webdriver (4.30.1) + selenium-webdriver (4.31.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 398be6855..81ff976bf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,8 @@ nav_order: 6 ## main +## 4.0.0.alpha3 + * BREAKING: Remove dependency on `ActionView::Base`, eliminating the need for capture compatibility patch. *Cameron Dutro* diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index c1dfed9b8..287a3c82c 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -5,7 +5,7 @@ module VERSION MAJOR = 4 MINOR = 0 PATCH = 0 - PRE = "alpha2" + PRE = "alpha3" STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".") end From 3ec795aa58e249cc060edc31cc7f4bc7d843323d Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 1 May 2025 10:25:41 -0600 Subject: [PATCH 047/158] simplify rendering test (#2290) With @camertron's change to remove the render compat patch, we were able to simplify our CI matrix to the point of no longer needing this conditional branching. --- test/sandbox/test/rendering_test.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 3b30bef29..a41dd2537 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -15,11 +15,8 @@ def test_render_inline_allocations ViewComponent::CompileCache.cache.delete(MyComponent) MyComponent.__vc_ensure_compiled - allocations = (Rails.version.to_f >= 8.0) ? - {"3.5" => 78, "3.4" => 84, "3.3" => 86} : {"3.3" => 84, "3.2" => 83} - with_instrumentation_enabled_option(false) do - assert_allocations(**allocations) do + assert_allocations({"3.5" => 78, "3.4" => 84, "3.3" => 84, "3.2" => 83}) do render_inline(MyComponent.new) end end From fcf4a788f82d9667cde3ddecc4a1bbb2f6dcfc34 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 9 May 2025 12:33:50 -0600 Subject: [PATCH 048/158] Graduate SlotableDefault to be included by default (#2291) * Graduate SlotableDefault to be included by default. * remove unused file * add failing test for slotable default override * Fix bug in SlotableDefault where default could not be overridden when content was passed as a block. Closes https://github.com/ViewComponent/view_component/issues/2169 Co-authored-by: Bill Watts * Fix final line endings * simplify normal slotable case to not assign unless needed * use elsif * lint * simplify control flow, add comments * copy lint * lint --------- Co-authored-by: Bill Watts Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> --- docs/CHANGELOG.md | 8 +++++++ docs/guide/slots.md | 8 ++----- lib/view_component/base.rb | 1 - lib/view_component/slotable.rb | 22 ++++++++++++++----- lib/view_component/slotable_default.rb | 20 ----------------- .../components/slotable_default_component.rb | 2 -- .../slotable_default_instance_component.rb | 2 -- .../slotable_default_override.html.erb | 5 +++++ test/sandbox/config/routes.rb | 1 + test/sandbox/test/integration_test.rb | 6 +++++ 10 files changed, 38 insertions(+), 37 deletions(-) delete mode 100644 lib/view_component/slotable_default.rb create mode 100644 test/sandbox/app/views/integration_examples/slotable_default_override.html.erb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 81ff976bf..4619800e4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,14 @@ nav_order: 6 ## main +* Graduate `SlotableDefault` to be included by default. + + *Joel Hawksley* + +* Fix bug in `SlotableDefault` where default couldn't be overridden when content was passed as a block. + + *Bill Watts*, *Joel Hawksley* + ## 4.0.0.alpha3 * BREAKING: Remove dependency on `ActionView::Base`, eliminating the need for capture compatibility patch. diff --git a/docs/guide/slots.md b/docs/guide/slots.md index 9886685b0..d4225ab09 100644 --- a/docs/guide/slots.md +++ b/docs/guide/slots.md @@ -352,15 +352,13 @@ The setters are now `#with_icon_visual` and `#with_avatar_visual` instead of the ## `#default_SLOT_NAME` -Since 3.14.0 +Since 4.0.0 {: .label } -To provide a default value for a slot, include the experimental `SlotableDefault` module and define a `default_SLOT_NAME` method: +To provide a default value for a slot, define a `default_SLOT_NAME` method: ```ruby class SlotableDefaultComponent < ViewComponent::Base - include SlotableDefault - renders_one :header def default_header @@ -373,8 +371,6 @@ end ```ruby class SlotableDefaultInstanceComponent < ViewComponent::Base - include SlotableDefault - renders_one :header def default_header diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 34edaba7b..792ed8414 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -12,7 +12,6 @@ require "view_component/preview" require "view_component/request_details" require "view_component/slotable" -require "view_component/slotable_default" require "view_component/template" require "view_component/translatable" require "view_component/with_content_helper" diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index 5d300d05c..40ee8aec9 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -351,16 +351,26 @@ def raise_if_slot_name_uncountable(slot_name) end def get_slot(slot_name) + @__vc_set_slots ||= {} content unless content_evaluated? # ensure content is loaded so slots will be defined - slot = self.class.registered_slots[slot_name] - @__vc_set_slots ||= {} + # If the slot is set, return it + return @__vc_set_slots[slot_name] if @__vc_set_slots[slot_name] - if @__vc_set_slots[slot_name] - return @__vc_set_slots[slot_name] - end + # If there is a default method for the slot, call it + if (default_method = registered_slots[slot_name][:default_method]) + renderable_value = send(default_method) + slot = Slot.new(self) + + if renderable_value.respond_to?(:render_in) + slot.__vc_component_instance = renderable_value + else + slot.__vc_content = renderable_value + end - if slot[:collection] + slot + elsif self.class.registered_slots[slot_name][:collection] + # If empty slot is a collection, return an empty array [] end end diff --git a/lib/view_component/slotable_default.rb b/lib/view_component/slotable_default.rb deleted file mode 100644 index 538622f01..000000000 --- a/lib/view_component/slotable_default.rb +++ /dev/null @@ -1,20 +0,0 @@ -module ViewComponent - module SlotableDefault - def get_slot(slot_name) - @__vc_set_slots ||= {} - - return super unless !@__vc_set_slots[slot_name] && (default_method = registered_slots[slot_name][:default_method]) - - renderable_value = send(default_method) - slot = Slot.new(self) - - if renderable_value.respond_to?(:render_in) - slot.__vc_component_instance = renderable_value - else - slot.__vc_content = renderable_value - end - - slot - end - end -end diff --git a/test/sandbox/app/components/slotable_default_component.rb b/test/sandbox/app/components/slotable_default_component.rb index 83d066fac..c32660d91 100644 --- a/test/sandbox/app/components/slotable_default_component.rb +++ b/test/sandbox/app/components/slotable_default_component.rb @@ -1,6 +1,4 @@ class SlotableDefaultComponent < ViewComponent::Base - include ViewComponent::SlotableDefault - erb_template <<~ERB

<%= header %>

ERB diff --git a/test/sandbox/app/components/slotable_default_instance_component.rb b/test/sandbox/app/components/slotable_default_instance_component.rb index 291c54fe6..012016eec 100644 --- a/test/sandbox/app/components/slotable_default_instance_component.rb +++ b/test/sandbox/app/components/slotable_default_instance_component.rb @@ -1,6 +1,4 @@ class SlotableDefaultInstanceComponent < ViewComponent::Base - include ViewComponent::SlotableDefault - erb_template <<~ERB

<%= header %>

ERB diff --git a/test/sandbox/app/views/integration_examples/slotable_default_override.html.erb b/test/sandbox/app/views/integration_examples/slotable_default_override.html.erb new file mode 100644 index 000000000..1090adde2 --- /dev/null +++ b/test/sandbox/app/views/integration_examples/slotable_default_override.html.erb @@ -0,0 +1,5 @@ +<%= render(SlotableDefaultComponent.new) do |c| %> + <% c.with_header do %> + foo + <% end %> +<% end %> diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb index f5b7a43a3..0ffb8847d 100644 --- a/test/sandbox/config/routes.rb +++ b/test/sandbox/config/routes.rb @@ -30,6 +30,7 @@ get :unsafe_preamble_component, to: "integration_examples#unsafe_preamble_component" get :unsafe_postamble_component, to: "integration_examples#unsafe_postamble_component" get :multiple_formats_component, to: "integration_examples#multiple_formats_component" + get :slotable_default_override, to: "integration_examples#slotable_default_override" post :create, to: "integration_examples#create" constraints(lambda { |request| request.env["warden"].authenticate! }) do diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index ca4e8c129..2afb260ef 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -717,4 +717,10 @@ def test_renders_multiple_format_component_as_css assert_includes response.body, "Hello, CSS!" end + + def test_slotable_default_override + get "/slotable_default_override" + + assert_includes response.body, "foo" + end end From 5ee6a1d3cb45677c1cb362d5b2cd12674c3f2966 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 12 May 2025 11:36:47 -0600 Subject: [PATCH 049/158] [v4] remove unnecessary initializer in Base (#2293) * remove unnecessary initializer in Base * add test case for component calling super in initializer --- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 6 ------ .../collection_parameter_with_active_model_component.rb | 3 +++ .../app/components/initialize_super_component.html.erb | 1 + test/sandbox/app/components/initialize_super_component.rb | 7 +++++++ .../views/integration_examples/cached_capture.html.erb | 2 +- test/sandbox/test/rendering_test.rb | 8 +++++++- test/sandbox/test/slotable_test.rb | 2 +- 8 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 test/sandbox/app/components/initialize_super_component.html.erb create mode 100644 test/sandbox/app/components/initialize_super_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4619800e4..30970c4f0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,10 @@ nav_order: 6 *Bill Watts*, *Joel Hawksley* +* BREAKING: Remove default initializer from `ViewComponent::Base`. Previously, `ViewComponent::Base` defined a catch-all initializer that allowed components without an initializer defined to be passed arbitrary arguments. + + *Joel Hawksley* + ## 4.0.0.alpha3 * BREAKING: Remove dependency on `ActionView::Base`, eliminating the need for capture compatibility patch. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 792ed8414..82999c114 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -215,12 +215,6 @@ def render? true end - # Override the ActionView::Base initializer so that components - # do not need to define their own initializers. - # @private - def initialize(*) - end - # Re-use original view_context if we're not rendering a component. # # This prevents an exception when rendering a partial inside of a component that has also been rendered outside diff --git a/test/sandbox/app/components/collection_parameter_with_active_model_component.rb b/test/sandbox/app/components/collection_parameter_with_active_model_component.rb index 20b180f34..664f8ea69 100644 --- a/test/sandbox/app/components/collection_parameter_with_active_model_component.rb +++ b/test/sandbox/app/components/collection_parameter_with_active_model_component.rb @@ -7,6 +7,9 @@ class CollectionParameterWithActiveModelComponent < ViewComponent::Base with_collection_parameter :name + def initialize(name:) + end + def call end end diff --git a/test/sandbox/app/components/initialize_super_component.html.erb b/test/sandbox/app/components/initialize_super_component.html.erb new file mode 100644 index 000000000..d2d68fe70 --- /dev/null +++ b/test/sandbox/app/components/initialize_super_component.html.erb @@ -0,0 +1 @@ +
hello,world!
diff --git a/test/sandbox/app/components/initialize_super_component.rb b/test/sandbox/app/components/initialize_super_component.rb new file mode 100644 index 000000000..5d5a9258c --- /dev/null +++ b/test/sandbox/app/components/initialize_super_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InitializeSuperComponent < ViewComponent::Base + def initialize + super + end +end diff --git a/test/sandbox/app/views/integration_examples/cached_capture.html.erb b/test/sandbox/app/views/integration_examples/cached_capture.html.erb index 11931f9aa..e54b71f10 100644 --- a/test/sandbox/app/views/integration_examples/cached_capture.html.erb +++ b/test/sandbox/app/views/integration_examples/cached_capture.html.erb @@ -1,5 +1,5 @@ <% cache ["true"] do %> - <%= render CapturingComponent.new(message: "message") do %> + <%= render CapturingComponent.new do %>
Hello
<% end %> <% end %> diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index a41dd2537..e0ad76eb6 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -16,7 +16,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 78, "3.4" => 84, "3.3" => 84, "3.2" => 83}) do + assert_allocations({"3.5" => 78, "3.4" => 83, "3.3" => 83, "3.2" => 82}) do render_inline(MyComponent.new) end end @@ -24,6 +24,12 @@ def test_render_inline_allocations assert_selector("div", text: "hello,world!") end + def test_initialize_super + render_inline(InitializeSuperComponent.new) + + assert_selector("div", text: "hello,world!") + end + def test_render_in_view_context render_in_view_context { render(MyComponent.new) } diff --git a/test/sandbox/test/slotable_test.rb b/test/sandbox/test/slotable_test.rb index 8ea493fac..e77f75939 100644 --- a/test/sandbox/test/slotable_test.rb +++ b/test/sandbox/test/slotable_test.rb @@ -147,7 +147,7 @@ def test_sub_component_raise_with_duplicate_slot_name end def test_sub_component_with_positional_args - render_inline(SlotsWithPosArgComponent.new(classes: "mt-4")) do |component| + render_inline(SlotsWithPosArgComponent.new) do |component| component.with_item("my item", classes: "hello") { "My rad item" } end From e81f9168e998b0d2f022b3e5ca40d3d1797a9600 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 14 May 2025 09:27:17 -0600 Subject: [PATCH 050/158] release 4.0.0.alpha4 --- Gemfile.lock | 2 +- docs/CHANGELOG.md | 10 ++++++---- lib/view_component/version.rb | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index cb826fcd6..8676208dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (4.0.0.alpha3) + view_component (4.0.0.alpha4) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 30970c4f0..e857705a5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,12 @@ nav_order: 6 ## main +## 4.0.0.alpha4 + +* BREAKING: Remove default initializer from `ViewComponent::Base`. Previously, `ViewComponent::Base` defined a catch-all initializer that allowed components without an initializer defined to be passed arbitrary arguments. + + *Joel Hawksley* + * Graduate `SlotableDefault` to be included by default. *Joel Hawksley* @@ -18,10 +24,6 @@ nav_order: 6 *Bill Watts*, *Joel Hawksley* -* BREAKING: Remove default initializer from `ViewComponent::Base`. Previously, `ViewComponent::Base` defined a catch-all initializer that allowed components without an initializer defined to be passed arbitrary arguments. - - *Joel Hawksley* - ## 4.0.0.alpha3 * BREAKING: Remove dependency on `ActionView::Base`, eliminating the need for capture compatibility patch. diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index 287a3c82c..ca0a54819 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -5,7 +5,7 @@ module VERSION MAJOR = 4 MINOR = 0 PATCH = 0 - PRE = "alpha3" + PRE = "alpha4" STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".") end From a97881159dc580c9695079e609b2a892707ac1d1 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 14 May 2025 15:22:36 -0600 Subject: [PATCH 051/158] [v4] BREAKING: Move config.view_component_path to config.generate.path (#2295) * Move config.view_component_path to config.generate.path In auditing config.view_component_path for the coming configuration changes we're making, I realized that our usage of the option was riddled with bugs. The issues all had to do with the underlying assumption that there can only be _one_ view_component_path, when in fact components can exist in any number of locations. For example, we used it to strip part of the component file path for generating translation keys. This of course only worked if the component was in the directory set in view_component_path. We also used it to populate the Rails statistics for ViewComponents. I opted to remove the stats functionality for now as we'd need a reliable way of counting components outside of a provided directory path. * add test case for translations for component in module * document support for global translations * Fix final line endings --------- Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> --- Rakefile | 1 - docs/CHANGELOG.md | 8 +++++++ docs/api.md | 6 ----- docs/guide/generators.md | 4 ++-- docs/guide/translations.md | 15 ++++++++++++ .../view_component/abstract_generator.rb | 2 +- lib/view_component/base.rb | 17 +------------- lib/view_component/config.rb | 20 ++++++++-------- lib/view_component/engine.rb | 18 --------------- .../rails/tasks/view_component.rake | 18 --------------- .../translatable_module_component.html.erb | 10 ++++++++ .../translatable_module_component.rb | 6 +++++ .../translatable_module_component.yml | 21 +++++++++++++++++ test/sandbox/config/locales/en.yml | 4 ++++ test/sandbox/test/config_test.rb | 2 +- .../test/generators/rspec_generator_test.rb | 2 +- test/sandbox/test/rake_tasks_test.rb | 20 ---------------- test/sandbox/test/tasks_test.rb | 23 ------------------- test/sandbox/test/translatable_test.rb | 15 ++++++++++++ test/test_engine/test/config_test.rb | 2 +- test/test_helper.rb | 2 +- 21 files changed, 97 insertions(+), 119 deletions(-) delete mode 100644 lib/view_component/rails/tasks/view_component.rake create mode 100644 test/sandbox/app/components/example_module/translatable_module_component.html.erb create mode 100644 test/sandbox/app/components/example_module/translatable_module_component.rb create mode 100644 test/sandbox/app/components/example_module/translatable_module_component.yml delete mode 100644 test/sandbox/test/rake_tasks_test.rb delete mode 100644 test/sandbox/test/tasks_test.rb diff --git a/Rakefile b/Rakefile index bb04d1133..5c053d7a2 100644 --- a/Rakefile +++ b/Rakefile @@ -101,7 +101,6 @@ namespace :docs do require "rails" require "action_controller" require "view_component" - ViewComponent::Base.config.view_component_path = "docs" require "docs/docs_builder_component" error_keys = registry.keys.select { |key| key.to_s.include?("Error::MESSAGE") }.map(&:to_s) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e857705a5..fb75f37c0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,14 @@ nav_order: 6 ## main +* BREAKING: `config.view_component_path` is now `config.generate.path`, as components have long since been able to exist in any directory. + + *Joel Hawksley* + +* BREAKING: Remove broken integration with `rails stats` that ignored components outside of `app/components`. + + *Joel Hawksley* + ## 4.0.0.alpha4 * BREAKING: Remove default initializer from `ViewComponent::Base`. Previously, `ViewComponent::Base` defined a catch-all initializer that allowed components without an initializer defined to be passed arbitrary arguments. diff --git a/docs/api.md b/docs/api.md index a4b081b0f..041f36eb5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -247,12 +247,6 @@ The controller used for testing components. Can also be configured on a per-test basis using `#with_controller_class`. Defaults to `ApplicationController`. -### `.view_component_path` - -The path in which components, their templates, and their sidecars should -be stored. -Defaults to `"app/components"`. - ## ViewComponent::TestHelpers ### `#render_in_view_context(*args, &block)` diff --git a/docs/guide/generators.md b/docs/guide/generators.md index 1c578ecd1..45adfb24b 100644 --- a/docs/guide/generators.md +++ b/docs/guide/generators.md @@ -38,11 +38,11 @@ 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). -Generated ViewComponents are added to `app/components` by default. Set `config.view_component.view_component_path` to use a different path. Note that you need to add the same path to `config.eager_load_paths` as well. +Generated ViewComponents are added to `app/components` by default. Set `config.view_component.generate.path` to use a different path. ```ruby # config/application.rb -config.view_component.view_component_path = "app/views/components" +config.view_component.generate.path = "app/views/components" config.eager_load_paths << Rails.root.join("app/views/components") ``` diff --git a/docs/guide/translations.md b/docs/guide/translations.md index 3124df8c8..0707addfa 100644 --- a/docs/guide/translations.md +++ b/docs/guide/translations.md @@ -42,6 +42,21 @@ Global Rails translations are available as well: <%= t("my.global.translation") %> ``` +Including translations namespaced under the component name: + +```yml +# config/locales/en.yml +en: + my_module: + example_component: + hello: "Hello world!" +``` + +```erb +<%# app/components/my_module/example_component.html.erb %> +<%= t(".hello") %> +``` + Access global translations via `helpers` or `I18n`: ```erb diff --git a/lib/generators/view_component/abstract_generator.rb b/lib/generators/view_component/abstract_generator.rb index 28fe568ad..505ecda07 100644 --- a/lib/generators/view_component/abstract_generator.rb +++ b/lib/generators/view_component/abstract_generator.rb @@ -29,7 +29,7 @@ def file_name end def component_path - ViewComponent::Base.config.view_component_path + ViewComponent::Base.config.generate.path end def stimulus_controller diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 5b12fedd4..08bc4d304 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -373,15 +373,6 @@ def safe_output_postamble # configured on a per-test basis using `with_controller_class`. # - # Path for component files - # - # ```ruby - # config.view_component.view_component_path = "app/my_components" - # ``` - # - # Defaults to `nil`. If this is falsy, `app/components` is used. - # - # Parent class for generated components # # ```ruby @@ -551,13 +542,7 @@ def render_template_for(requested_details) # We use `base_label` method here instead of `label` to avoid cases where the method # owner is included in a prefix like `ApplicationComponent.inherited`. child.identifier = caller_locations(1, 10).reject { |l| l.base_label == "inherited" }[0].path - - # If Rails application is loaded, removes the first part of the path and the extension. - if defined?(Rails) && Rails.application - child.virtual_path = child.identifier.gsub( - /(.*#{Regexp.quote(ViewComponent::Base.config.view_component_path)})|(\.rb)/, "" - ) - end + child.virtual_path = child.name&.underscore # Set collection parameter to the extended component child.with_collection_parameter provided_collection_parameter diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index b427ac50a..668eaf8eb 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -16,7 +16,6 @@ def defaults preview_controller: "ViewComponentsController", preview_route: "/rails/view_components", instrumentation_enabled: false, - view_component_path: "app/components", component_parent_class: nil, show_previews: Rails.env.development? || Rails.env.test?, preview_paths: default_preview_paths, @@ -32,6 +31,12 @@ def defaults # All options under this namespace default to `false` unless otherwise # stated. # + # #### `#path` + # + # Where to put generated components. Defaults to `app/components`: + # + # config.view_component.generate.path = "lib/components" + # # #### `#sidecar` # # Always generate a component with a sidecar directory: @@ -84,14 +89,14 @@ def defaults # # #### `#use_component_path_for_rspec_tests` # - # Whether to use the `config.view_component_path` when generating new + # Whether to use `config.generate.path` when generating new # RSpec component tests: # # config.view_component.generate.use_component_path_for_rspec_tests = true # - # When set to `true`, the generator will use the `view_component_path` to + # When set to `true`, the generator will use the `path` to # decide where to generate the new RSpec component test. - # For example, if the `view_component_path` is + # For example, if the `path` is # `app/views/components`, then the generator will create a new spec file # in `spec/views/components/` rather than the default `spec/components/`. @@ -110,12 +115,6 @@ def defaults # Whether ActiveSupport notifications are enabled. # Defaults to `false`. - # @!attribute view_component_path - # @return [String] - # The path in which components, their templates, and their sidecars should - # be stored. - # Defaults to `"app/components"`. - # @!attribute component_parent_class # @return [String] # The parent class from which generated components will inherit. @@ -171,6 +170,7 @@ def registered_rails_engines_with_previews def default_generate_options options = ActiveSupport::OrderedOptions.new(false) options.preview_path = "" + options.path = "app/components" options end end diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index a0f1a4664..8143d2ac0 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -8,24 +8,6 @@ module ViewComponent class Engine < Rails::Engine # :nodoc: config.view_component = ViewComponent::Config.current - if Rails.version.to_f < 8.0 - rake_tasks do - load "view_component/rails/tasks/view_component.rake" - end - else - initializer "view_component.stats_directories" do |app| - require "rails/code_statistics" - - if Rails.root.join(ViewComponent::Base.view_component_path).directory? - Rails::CodeStatistics.register_directory("ViewComponents", ViewComponent::Base.view_component_path) - end - - if Rails.root.join("test/components").directory? - Rails::CodeStatistics.register_directory("ViewComponent tests", "test/components", test_directory: true) - end - end - end - initializer "view_component.set_configs" do |app| options = app.config.view_component diff --git a/lib/view_component/rails/tasks/view_component.rake b/lib/view_component/rails/tasks/view_component.rake deleted file mode 100644 index 40ec386f2..000000000 --- a/lib/view_component/rails/tasks/view_component.rake +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -task stats: "view_component:statsetup" - -namespace :view_component do - task :statsetup do - require "rails/code_statistics" - - if Rails.root.join(ViewComponent::Base.view_component_path).directory? - ::STATS_DIRECTORIES << ["ViewComponents", ViewComponent::Base.view_component_path] - end - - if Rails.root.join("test/components").directory? - ::STATS_DIRECTORIES << ["ViewComponent tests", "test/components"] - CodeStatistics::TEST_TYPES << "ViewComponent tests" - end - end -end diff --git a/test/sandbox/app/components/example_module/translatable_module_component.html.erb b/test/sandbox/app/components/example_module/translatable_module_component.html.erb new file mode 100644 index 000000000..02b45438c --- /dev/null +++ b/test/sandbox/app/components/example_module/translatable_module_component.html.erb @@ -0,0 +1,10 @@ +

<%= t(".hello") %>

+

<%= t(".from.sidecar") %>

+

<%= t("from.rails") %>

+ +

<%= ::I18n.t("hello") %>

+

<%= ::I18n.t("from.rails") %>

+ +

<%= helpers.t("hello") %>

+

<%= helpers.t("from.rails") %>

+

<%= helpers.t(".relative_rails_key") %>

diff --git a/test/sandbox/app/components/example_module/translatable_module_component.rb b/test/sandbox/app/components/example_module/translatable_module_component.rb new file mode 100644 index 000000000..bcf9e2598 --- /dev/null +++ b/test/sandbox/app/components/example_module/translatable_module_component.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ExampleModule + class TranslatableModuleComponent < ViewComponent::Base + end +end diff --git a/test/sandbox/app/components/example_module/translatable_module_component.yml b/test/sandbox/app/components/example_module/translatable_module_component.yml new file mode 100644 index 000000000..cc741b517 --- /dev/null +++ b/test/sandbox/app/components/example_module/translatable_module_component.yml @@ -0,0 +1,21 @@ +en: + hello: "Hello from sidecar translations!" + + hello_html: "Hello from sidecar translations!" + + interpolated_html: "There are %{horse_count} horses in the barn!" + + html: "hello world!" + + from: + sidecar: This is coming from the sidecar + + list: + - This + - returns + - a list + + list_html: + - This + - returns + - a list with embedded HTML diff --git a/test/sandbox/config/locales/en.yml b/test/sandbox/config/locales/en.yml index d9e517d08..1c651e422 100644 --- a/test/sandbox/config/locales/en.yml +++ b/test/sandbox/config/locales/en.yml @@ -6,6 +6,10 @@ en: title: Lorem ipsum subtitle: More lorem ipsum + example_module: + translatable_module_component: + relative_rails_key: Relative key from Rails for module + translatable_component: relative_rails_key: Relative key from Rails diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index d73b2d368..e67c04119 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -9,7 +9,7 @@ def setup end def test_defaults_are_correct - assert_equal @config.generate, {preview_path: ""} + assert_equal @config.generate, {preview_path: "", path: "app/components"} assert_equal @config.preview_controller, "ViewComponentsController" assert_equal @config.preview_route, "/rails/view_components" assert_equal @config.instrumentation_enabled, false diff --git a/test/sandbox/test/generators/rspec_generator_test.rb b/test/sandbox/test/generators/rspec_generator_test.rb index a65048529..4a36c3172 100644 --- a/test/sandbox/test/generators/rspec_generator_test.rb +++ b/test/sandbox/test/generators/rspec_generator_test.rb @@ -60,7 +60,7 @@ def test_generates_component_with_different_component_path_without_config_flag def test_generates_component_with_non_app_component_path with_generate_option(:use_component_path_for_rspec_tests, true) do - with_config_option(:view_component_path, "lib/views/components") do + with_custom_component_path("lib/views/components") do run_generator %w[Dummy data] assert_file "spec/components/dummy_component_spec.rb" do |content| diff --git a/test/sandbox/test/rake_tasks_test.rb b/test/sandbox/test/rake_tasks_test.rb deleted file mode 100644 index 35d493226..000000000 --- a/test/sandbox/test/rake_tasks_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -if Rails.version.to_f < 8.0 - module ViewComponent - class RakeTasksTest < TestCase - def setup - Kernel.silence_warnings do - Sandbox::Application.load_tasks - end - end - - def test_statsetup_task - Rake::Task["view_component:statsetup"].invoke - assert_includes ::STATS_DIRECTORIES, ["ViewComponents", "app/components"] - end - end - end -end diff --git a/test/sandbox/test/tasks_test.rb b/test/sandbox/test/tasks_test.rb deleted file mode 100644 index 8536d0160..000000000 --- a/test/sandbox/test/tasks_test.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class TasksTest < ActiveSupport::TestCase - setup do - Kernel.silence_warnings do - Rails.application.load_tasks - end - end - - teardown do - Rake.application.clear - end - - test "adds components to rails stats" do - Dir.chdir(Rails.root) do - assert_output(/ViewComponents/) do - Rake::Task["stats"].invoke - end - end - end -end diff --git a/test/sandbox/test/translatable_test.rb b/test/sandbox/test/translatable_test.rb index 5dfc04ceb..4beedaa91 100644 --- a/test/sandbox/test/translatable_test.rb +++ b/test/sandbox/test/translatable_test.rb @@ -17,6 +17,21 @@ def test_isolated_translations assert_selector("p.global.nested", text: "This is coming from Rails") end + def test_isolated_translations_in_module + render_inline(ExampleModule::TranslatableModuleComponent.new) + + assert_selector("p.sidecar.shared-key", text: "Hello from sidecar translations!") + assert_selector("p.sidecar.nested", text: "This is coming from the sidecar") + assert_selector("p.sidecar.missing", text: "This is coming from Rails") + + assert_selector("p.helpers.shared-key", text: "Hello from Rails translations!") + assert_selector("p.helpers.nested", text: "This is coming from Rails") + assert_selector("p.helpers.relative", text: "Relative key from Rails for module") + + assert_selector("p.global.shared-key", text: "Hello from Rails translations!") + assert_selector("p.global.nested", text: "This is coming from Rails") + end + def test_multi_key_support assert_equal( [ diff --git a/test/test_engine/test/config_test.rb b/test/test_engine/test/config_test.rb index cff1cf8da..6bcaf572e 100644 --- a/test/test_engine/test/config_test.rb +++ b/test/test_engine/test/config_test.rb @@ -9,7 +9,7 @@ def setup end def test_defaults_are_correct - assert_equal @config.generate, {preview_path: ""} + assert_equal @config.generate, {preview_path: "", path: "app/components"} assert_equal @config.preview_controller, "ViewComponentsController" assert_equal @config.preview_route, "/rails/view_components" assert_equal @config.instrumentation_enabled, false diff --git a/test/test_helper.rb b/test/test_helper.rb index 2a4ef7077..7c70e5a46 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -92,7 +92,7 @@ def with_preview_controller(new_value) end def with_custom_component_path(new_value, &block) - with_config_option(:view_component_path, new_value, &block) + with_generate_option(:path, new_value, &block) end def with_custom_component_parent_class(new_value, &block) From afbac9b11cd7e6824b944812a765838ff85a3ee9 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 15 May 2025 15:36:02 -0600 Subject: [PATCH 052/158] Pre-allocate instance variables for better compatibility with Object Shapes (#2282) * wip * initialize __vc_content to reduce object shapes * more shape optimizations * TDD for object shapes * set @lookup_context at allocation * pre allocate all base instance variables * optimize slots for object shapes * fix slotable rendering * pre-allocate content check * more pre-allocation * more pre-allocations * Add benchmark * add new implementation, more benchmarks * Fix final line endings * allocations * lints * add changelog Co-authored-by: Adam Hess * try pre-initializing in rendering test * allocation counts * fix allocations * fix case where PVC depended on being able to set slot to nil --------- Co-authored-by: Adam Hess Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> --- docs/CHANGELOG.md | 4 ++ lib/view_component/base.rb | 66 +++++++++++++------ lib/view_component/inline_template.rb | 6 +- lib/view_component/slot.rb | 25 ++++--- lib/view_component/slotable.rb | 4 +- lib/view_component/slotable_default.rb | 18 +++++ .../components/complex_component.html.erb | 5 ++ performance/components/complex_component.rb | 8 +++ performance/components/name_component.rb | 3 + .../nested_complex_component.html.erb | 1 + .../components/nested_complex_component.rb | 10 +++ .../components/nested_name_component.rb | 3 + performance/partial_benchmark.rb | 7 ++ .../app/components/object_shapes_component.rb | 14 ++++ test/sandbox/test/rendering_test.rb | 28 +++++++- test/sandbox/test/slotable_test.rb | 24 +++++++ test/test_helper.rb | 2 + 17 files changed, 191 insertions(+), 37 deletions(-) create mode 100644 lib/view_component/slotable_default.rb create mode 100644 performance/components/complex_component.html.erb create mode 100644 performance/components/complex_component.rb create mode 100644 performance/components/nested_complex_component.html.erb create mode 100644 performance/components/nested_complex_component.rb create mode 100644 test/sandbox/app/components/object_shapes_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fb75f37c0..66df0c542 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,10 @@ nav_order: 6 *Joel Hawksley* +* Add internal optimization for Ruby object shapes. + + *Adam Hess*, *Joel Hawksley* + ## 4.0.0.alpha4 * BREAKING: Remove default initializer from `ViewComponent::Base`. Previously, `ViewComponent::Base` defined a catch-all initializer that allowed components without an initializer defined to be passed arbitrary arguments. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 08bc4d304..5e9fa7412 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -35,6 +35,15 @@ class Base class << self delegate(*ViewComponent::Config.defaults.keys, to: :config) + # Redefine `new` so we can pre-allocate instance variables to optimize + # for Ruby object shapes. + def new(...) + instance = allocate + instance.__vc_pre_allocate_instance_variables + instance.send(:initialize, ...) + instance + end + # Returns the current config. # # @return [ActiveSupport::OrderedOptions] @@ -47,6 +56,29 @@ def config end end + def __vc_pre_allocate_instance_variables + @__vc_parent_render_level = 0 + @__vc_set_slots = {} + @__vc_content_evaluated = false + @current_template = nil + @output_buffer = nil + @lookup_context = nil + @view_flow = nil + @view_context = nil + @virtual_path = nil + @__vc_ancestor_calls = nil + @__vc_controller = nil + @__vc_content = :unset # some behaviors depend on checking for nil + @__vc_content_set_by_with_content = nil + @__vc_helpers = nil + @__vc_inline_template = nil + @__vc_inline_template_defined = nil + @__vc_render_in_block = nil + @__vc_request = nil + @__vc_requested_details = nil + @__vc_original_view_context = nil + end + include ActionView::Helpers include ERB::Escape include ActiveSupport::CoreExt::ERBUtil @@ -113,14 +145,12 @@ def render_in(view_context, &block) @__vc_requested_details ||= @lookup_context.vc_requested_details # For caching, such as #cache_if - @current_template = nil unless defined?(@current_template) old_current_template = @current_template - if block && defined?(@__vc_content_set_by_with_content) + if block && __vc_content_set_by_with_content? raise DuplicateContentError.new(self.class.name) end - @__vc_content_evaluated = false @__vc_render_in_block = block before_render @@ -174,16 +204,12 @@ def render_parent # # When rendering the parent inside an .erb template, use `#render_parent` instead. def render_parent_to_string - @__vc_parent_render_level ||= 0 # ensure a good starting value - - begin - target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] - @__vc_parent_render_level += 1 + target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] + @__vc_parent_render_level += 1 - target_render.bind_call(self, @__vc_requested_details) - ensure - @__vc_parent_render_level -= 1 - end + target_render.bind_call(self, @__vc_requested_details) + ensure + @__vc_parent_render_level -= 1 end # Optional content to be returned before the rendered template. @@ -306,12 +332,12 @@ def __vc_request # @return [String] def content @__vc_content_evaluated = true - return @__vc_content if defined?(@__vc_content) + return @__vc_content if @__vc_content != :unset @__vc_content = if __vc_render_in_block_provided? view_context.capture(self, &@__vc_render_in_block) - elsif __vc_content_set_by_with_content_defined? + elsif __vc_content_set_by_with_content? @__vc_content_set_by_with_content end end @@ -320,7 +346,7 @@ def content # # @return [Boolean] def content? - __vc_render_in_block_provided? || __vc_content_set_by_with_content_defined? + __vc_render_in_block_provided? || __vc_content_set_by_with_content? end private @@ -328,15 +354,15 @@ def content? attr_reader :view_context def __vc_render_in_block_provided? - defined?(@view_context) && @view_context && @__vc_render_in_block + @view_context && @__vc_render_in_block end - def __vc_content_set_by_with_content_defined? - defined?(@__vc_content_set_by_with_content) + def __vc_content_set_by_with_content? + !@__vc_content_set_by_with_content.nil? end def content_evaluated? - defined?(@__vc_content_evaluated) && @__vc_content_evaluated + @__vc_content_evaluated end def maybe_escape_html(text) @@ -548,7 +574,7 @@ def render_template_for(requested_details) child.with_collection_parameter provided_collection_parameter if instance_methods(false).include?(:render_template_for) - vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] + vc_ancestor_calls = (!@__vc_ancestor_calls.nil?) ? @__vc_ancestor_calls.dup : [] vc_ancestor_calls.unshift(instance_method(:render_template_for)) child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls) diff --git a/lib/view_component/inline_template.rb b/lib/view_component/inline_template.rb index 5095746ba..ae7ff3611 100644 --- a/lib/view_component/inline_template.rb +++ b/lib/view_component/inline_template.rb @@ -9,7 +9,7 @@ module InlineTemplate def method_missing(method, *args) return super if !method.end_with?("_template") - if defined?(@__vc_inline_template_defined) && @__vc_inline_template_defined + if @__vc_inline_template_defined raise MultipleInlineTemplatesError end @@ -38,11 +38,11 @@ def respond_to_missing?(method, include_all = false) end def inline_template - @__vc_inline_template if defined?(@__vc_inline_template) + @__vc_inline_template end def __vc_inline_template_language - @__vc_inline_template_language if defined?(@__vc_inline_template_language) + @__vc_inline_template_language end def inherited(subclass) diff --git a/lib/view_component/slot.rb b/lib/view_component/slot.rb index d5c745ec9..b8ef38f64 100644 --- a/lib/view_component/slot.rb +++ b/lib/view_component/slot.rb @@ -9,13 +9,18 @@ class Slot attr_writer :__vc_component_instance, :__vc_content_block, :__vc_content def initialize(parent) + @content = nil + @__vc_component_instance = nil + @__vc_content = nil + @__vc_content_block = nil + @__vc_content_set_by_with_content = nil @parent = parent end def content? - return true if defined?(@__vc_content) && @__vc_content.present? - return true if defined?(@__vc_content_set_by_with_content) && @__vc_content_set_by_with_content.present? - return true if defined?(@__vc_content_block) && @__vc_content_block.present? + return true if @__vc_content.present? + return true if @__vc_content_set_by_with_content.present? + return true if @__vc_content_block.present? return false if !__vc_component_instance? @__vc_component_instance.content? @@ -43,11 +48,11 @@ def with_content(args) # If there is no slot renderable, we evaluate the block passed to # the slot and return it. def to_s - return @content if defined?(@content) + return @content if !@content.nil? view_context = @parent.send(:view_context) - if defined?(@__vc_content_block) && defined?(@__vc_content_set_by_with_content) + if !@__vc_content_block.nil? && !@__vc_content_set_by_with_content.nil? && !@__vc_content_set_by_with_content.nil? raise DuplicateSlotContentError.new(self.class.name) end @@ -55,7 +60,7 @@ def to_s if __vc_component_instance? @__vc_component_instance.__vc_original_view_context = @parent.__vc_original_view_context - if defined?(@__vc_content_block) + if !@__vc_content_block.nil? # render_in is faster than `parent.render` @__vc_component_instance.render_in(view_context) do |*args| @__vc_content_block.call(*args) @@ -63,11 +68,11 @@ def to_s else @__vc_component_instance.render_in(view_context) end - elsif defined?(@__vc_content) + elsif !@__vc_content.nil? @__vc_content - elsif defined?(@__vc_content_block) + elsif !@__vc_content_block.nil? view_context.capture(&@__vc_content_block) - elsif defined?(@__vc_content_set_by_with_content) + elsif !@__vc_content_set_by_with_content.nil? @__vc_content_set_by_with_content end @@ -108,7 +113,7 @@ def respond_to_missing?(symbol, include_all = false) private def __vc_component_instance? - defined?(@__vc_component_instance) + !@__vc_component_instance.nil? end end end diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index 40ee8aec9..2d697d3ef 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -420,8 +420,6 @@ def set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block) end end - @__vc_set_slots ||= {} - if slot_definition[:collection] @__vc_set_slots[slot_name] ||= [] @__vc_set_slots[slot_name].push(slot) @@ -435,7 +433,7 @@ def set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block) def set_polymorphic_slot(slot_name, poly_type = nil, *args, **kwargs, &block) slot_definition = self.class.registered_slots[slot_name] - if !slot_definition[:collection] && defined?(@__vc_set_slots) && @__vc_set_slots[slot_name] + if !slot_definition[:collection] && @__vc_set_slots[slot_name] raise ContentAlreadySetForPolymorphicSlotError.new(slot_name) end diff --git a/lib/view_component/slotable_default.rb b/lib/view_component/slotable_default.rb new file mode 100644 index 000000000..9cc62c482 --- /dev/null +++ b/lib/view_component/slotable_default.rb @@ -0,0 +1,18 @@ +module ViewComponent + module SlotableDefault + def get_slot(slot_name) + return super unless !@__vc_set_slots[slot_name] && (default_method = registered_slots[slot_name][:default_method]) + + renderable_value = send(default_method) + slot = Slot.new(self) + + if renderable_value.respond_to?(:render_in) + slot.__vc_component_instance = renderable_value + else + slot.__vc_content = renderable_value + end + + slot + end + end +end diff --git a/performance/components/complex_component.html.erb b/performance/components/complex_component.html.erb new file mode 100644 index 000000000..9ff6dfed7 --- /dev/null +++ b/performance/components/complex_component.html.erb @@ -0,0 +1,5 @@ +

hello <%= @name %>

+ +<% 50.times do %> + <%= render Performance::NestedComplexComponent.new(name: @name) %> +<% end %> diff --git a/performance/components/complex_component.rb b/performance/components/complex_component.rb new file mode 100644 index 000000000..8113b64b7 --- /dev/null +++ b/performance/components/complex_component.rb @@ -0,0 +1,8 @@ +class Performance::ComplexComponent < ViewComponent::Base + def initialize(name:) + ("a"..."z").to_a.shuffle.each do |c| + instance_variable_set("@rand_var_#{c}", true) + end + @name = name + end +end diff --git a/performance/components/name_component.rb b/performance/components/name_component.rb index 05e6c0cf1..6ee6ffff4 100644 --- a/performance/components/name_component.rb +++ b/performance/components/name_component.rb @@ -2,6 +2,9 @@ class Performance::NameComponent < ViewComponent::Base def initialize(name:) + ("a"..."z").to_a.each do |c| + instance_variable_set("@rand_var_#{c}", true) + end @name = name end end diff --git a/performance/components/nested_complex_component.html.erb b/performance/components/nested_complex_component.html.erb new file mode 100644 index 000000000..94d827179 --- /dev/null +++ b/performance/components/nested_complex_component.html.erb @@ -0,0 +1 @@ +

nested hello <%= @name %>

diff --git a/performance/components/nested_complex_component.rb b/performance/components/nested_complex_component.rb new file mode 100644 index 000000000..16f607352 --- /dev/null +++ b/performance/components/nested_complex_component.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Performance::NestedComplexComponent < ViewComponent::Base + def initialize(name:) + ("a"..."z").to_a.shuffle.each do |c| + instance_variable_set("@rand_var_#{c}", true) + end + @name = name + end +end diff --git a/performance/components/nested_name_component.rb b/performance/components/nested_name_component.rb index 7bb97f234..d149c1389 100644 --- a/performance/components/nested_name_component.rb +++ b/performance/components/nested_name_component.rb @@ -2,6 +2,9 @@ class Performance::NestedNameComponent < ViewComponent::Base def initialize(name:) + ("a"..."z").to_a.each do |c| + instance_variable_set("@rand_var_#{c}", true) + end @name = name end end diff --git a/performance/partial_benchmark.rb b/performance/partial_benchmark.rb index 5de7014ee..15cb7f7b4 100644 --- a/performance/partial_benchmark.rb +++ b/performance/partial_benchmark.rb @@ -5,6 +5,8 @@ require "benchmark/ips" +Warning[:performance] = true + # Configure Rails Environment ENV["RAILS_ENV"] = "production" require File.expand_path("../test/sandbox/config/environment.rb", __dir__) @@ -13,6 +15,8 @@ module Performance require_relative "components/name_component" require_relative "components/nested_name_component" require_relative "components/inline_component" + require_relative "components/complex_component" + require_relative "components/nested_complex_component" end class BenchmarksController < ActionController::Base @@ -21,12 +25,15 @@ class BenchmarksController < ActionController::Base BenchmarksController.view_paths = [File.expand_path("./views", __dir__)] controller_view = BenchmarksController.new.view_context +controller_view.render(Performance::ComplexComponent.new(name: "HI there")) + Benchmark.ips do |x| x.time = 10 x.warmup = 2 x.report("component") { controller_view.render(Performance::NameComponent.new(name: "Fox Mulder")) } x.report("inline") { controller_view.render(Performance::InlineComponent.new(name: "Fox Mulder")) } + x.report("complex") { controller_view.render(Performance::ComplexComponent.new(name: "Fox Mulder")) } x.report("partial") { controller_view.render("partial", name: "Fox Mulder") } x.compare! diff --git a/test/sandbox/app/components/object_shapes_component.rb b/test/sandbox/app/components/object_shapes_component.rb new file mode 100644 index 000000000..c20af085e --- /dev/null +++ b/test/sandbox/app/components/object_shapes_component.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ObjectShapesComponent < ViewComponent::Base + def initialize(name:) + ("a"..."z").to_a.shuffle.each do |c| + instance_variable_set("@rand_var_#{c}", true) + end + @name = name + end + + def call + @name.to_s.html_safe + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index e0ad76eb6..2d1dbaf99 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -3,6 +3,20 @@ require "test_helper" class RenderingTest < ViewComponent::TestCase + def self.new(...) + instance = allocate + instance.__allocate_instance_variables + instance.send(:initialize, ...) + instance + end + + def __allocate_instance_variables + @page = nil + @rendered_content = nil + @vc_test_controller = nil + @vc_test_request = nil + end + def test_render_inline render_inline(MyComponent.new) @@ -16,7 +30,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 78, "3.4" => 83, "3.3" => 83, "3.2" => 82}) do + assert_allocations({"3.5" => 79, "3.4" => 84, "3.3" => 86, "3.2" => 85}) do render_inline(MyComponent.new) end end @@ -1260,6 +1274,18 @@ def test_render_anonymous_component_without_template end end + # Ensure that we pre-initialize all internal instance variables + # before rendering the component, maximizing the chance that + # Ruby will be able to use the more streamlined instance variable + # lookup enabled by object shapes. + def test_object_shapes + component = ObjectShapesComponent.new(name: SecureRandom.hex(10)) + + render_inline(component) + + assert_equal(component.instance_variables.last, :@name) + end + def test_current_template component = CurrentTemplateComponent.new diff --git a/test/sandbox/test/slotable_test.rb b/test/sandbox/test/slotable_test.rb index e77f75939..80ebd8ae4 100644 --- a/test/sandbox/test/slotable_test.rb +++ b/test/sandbox/test/slotable_test.rb @@ -821,4 +821,28 @@ def test_overridden_slot_name_can_be_inherited def test_slot_name_methods_are_not_shared_accross_components assert_not_equal SlotsComponent.instance_method(:title).owner, SlotNameOverrideComponent::OtherComponent.instance_method(:title).owner end + + # Ensure that we pre-initialize all internal instance variables + # before rendering the component, maximizing the chance that + # Ruby will be able to use the more streamlined instance variable + # lookup enabled by object shapes. + def test_object_shapes + component = SlotsComponent.new(classes: "mt-4") do |component| + component.with_title.with_content("This is my title!") + component.with_subtitle.with_content("This is my subtitle!") + component.with_tab.with_content("Tab A") + component.with_tab.with_content("Tab B") + component.with_item.with_content("Item A") + component.with_item(highlighted: true).with_content("Item B") + component.with_item.with_content("Item C") + + component.with_footer(classes: "text-blue") do + "This is the footer" + end + end + + render_inline(component) + + assert_equal(:@classes, component.instance_variables.last) + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 7c70e5a46..bc4b84061 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,6 +5,8 @@ require "simplecov-console" require "rails/version" +Warning[:performance] = true if RUBY_VERSION >= "3.4" + if ENV["MEASURE_COVERAGE"] SimpleCov.start do command_name "minitest-rails#{Rails::VERSION::STRING}-ruby#{RUBY_VERSION}" From 44fc9f4bd74917d17131d36432b6520bd7919ffe Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 15 May 2025 16:28:09 -0600 Subject: [PATCH 053/158] try pinning rack main to fix 3.5 rails main ci (#2297) * try pinning rack main to fix 3.5 rails main ci * cleanup * does this make rack happy? * try rack config via appraisal --- Appraisals | 1 + Gemfile.lock | 35 ++++++++++++++++++----------------- gemfiles/rails_main.gemfile | 1 + 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Appraisals b/Appraisals index 4227804c0..3037f6841 100644 --- a/Appraisals +++ b/Appraisals @@ -20,6 +20,7 @@ appraise "rails-8.0" do end appraise "rails-main" do + gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" gem "rails", github: "rails/rails", branch: "main" gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 2" diff --git a/Gemfile.lock b/Gemfile.lock index 8676208dd..b0e99361d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,11 +112,11 @@ GEM concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) - cuprite (0.15.1) + cuprite (0.17) capybara (~> 3.0) - ferrum (~> 0.15.0) + ferrum (~> 0.17.0) date (3.4.1) - diff-lcs (1.6.1) + diff-lcs (1.6.2) docile (1.4.1) drb (2.2.1) erb_lint (0.9.0) @@ -127,8 +127,9 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) - ferrum (0.15) + ferrum (0.17.1) addressable (~> 2.5) + base64 (~> 0.2) concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) @@ -148,11 +149,11 @@ GEM jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.11.3) - language_server-protocol (3.17.0.4) + json (2.12.0) + language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) m (1.6.2) @@ -207,15 +208,15 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.3) + psych (5.2.6) date stringio public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.13) - rack-session (2.1.0) + rack (3.1.1) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -265,10 +266,10 @@ GEM rexml (3.4.1) rspec-core (3.13.3) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) + rspec-mocks (3.13.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-rails (7.1.1) @@ -279,8 +280,8 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.2) - rubocop (1.75.4) + rspec-support (3.13.3) + rubocop (1.75.6) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -304,7 +305,7 @@ GEM ruby-progressbar (1.13.0) rubyzip (2.4.1) securerandom (0.4.1) - selenium-webdriver (4.31.0) + selenium-webdriver (4.32.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -332,10 +333,10 @@ GEM actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - standard (1.49.0) + standard (1.50.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.75.2) + rubocop (~> 1.75.5) standard-custom (~> 1.0.0) standard-performance (~> 1.8) standard-custom (1.0.2) diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index faeacaaf7..a4418c98b 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" gem "rails", github: "rails/rails", branch: "main" +gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd7817e4a8ed684f29b0" gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 2" From ca8ae8ab505a52bd6fd554d52183a6f9a570f0b8 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 15 May 2025 16:29:54 -0600 Subject: [PATCH 054/158] release 4.0.0.alpha5 --- Gemfile.lock | 2 +- docs/CHANGELOG.md | 2 ++ lib/view_component/version.rb | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b0e99361d..6653b25d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (4.0.0.alpha4) + view_component (4.0.0.alpha5) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 66df0c542..b5fd6216e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,8 @@ nav_order: 6 ## main +## 4.0.0.alpha5 + * BREAKING: `config.view_component_path` is now `config.generate.path`, as components have long since been able to exist in any directory. *Joel Hawksley* diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index ca0a54819..7f10e7f5b 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -5,7 +5,7 @@ module VERSION MAJOR = 4 MINOR = 0 PATCH = 0 - PRE = "alpha4" + PRE = "alpha5" STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".") end From 36e71161355ac709c4c94e90b4eb9e77b2d5d5cd Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 May 2025 15:19:15 -0600 Subject: [PATCH 055/158] Do not cache classes in test env (#2298) I've decided to prioritize testing code reloading, which has broken before, over testing compilation of an unreferenced component, which hasn't caught any regressions to my knowledge. This resolves the last of the skips in the test suite. --- .../components/unreferenced_component.html.erb | 1 - .../app/components/unreferenced_component.rb | 4 ---- test/sandbox/config/environments/test.rb | 2 +- test/sandbox/test/integration_test.rb | 4 ---- test/sandbox/test/rendering_test.rb | 16 ---------------- 5 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 test/sandbox/app/components/unreferenced_component.html.erb delete mode 100644 test/sandbox/app/components/unreferenced_component.rb diff --git a/test/sandbox/app/components/unreferenced_component.html.erb b/test/sandbox/app/components/unreferenced_component.html.erb deleted file mode 100644 index 1038ae1b1..000000000 --- a/test/sandbox/app/components/unreferenced_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -
Hello, World!
diff --git a/test/sandbox/app/components/unreferenced_component.rb b/test/sandbox/app/components/unreferenced_component.rb deleted file mode 100644 index 7eb74474d..000000000 --- a/test/sandbox/app/components/unreferenced_component.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class UnreferencedComponent < ViewComponent::Base -end diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index 1eedbc47a..12d0e1ef5 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -16,7 +16,7 @@ # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! - config.cache_classes = true + config.cache_classes = false # Show full error reports and disable caching config.consider_all_requests_local = true diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index d8de70194..bee91ddd8 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -127,8 +127,6 @@ def test_inherited_component_with_call_method_does_not_recompile_superclass end def test_helper_changes_are_reflected_on_new_request - skip if Rails.application.config.cache_classes - get "/helpers_proxy_component" assert_select("div", "Hello helper method") assert_response :success @@ -152,8 +150,6 @@ def message end def test_helper_changes_are_reflected_on_new_request_with_previews - skip if Rails.application.config.cache_classes - with_preview_route("/previews") do get "/previews/helpers_proxy_component/default" assert_select("div", "Hello helper method") diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index c45986676..cab3f1831 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -449,22 +449,6 @@ def test_validations_component assert_includes exception.message, "Validation failed: Content" end - def test_compiles_unrendered_component - # The UnreferencedComponent will get compiled at boot, - # but that might have been thrown away if code-reloading is enabled - skip unless Rails.application.config.cache_classes - - assert UnreferencedComponent.__vc_compiled? - end - - def test_compiles_components_without_initializers - # MissingInitializerComponent will get compiled at boot, - # but that might have been thrown away if code-reloading is enabled - skip unless Rails.application.config.cache_classes - - assert MissingInitializerComponent.__vc_compiled? - end - def test_renders_when_initializer_is_not_defined render_inline(MissingInitializerComponent.new) From 004274a7c3c8cb8332965b85c071597223ca915f Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 19 May 2025 16:32:09 -0600 Subject: [PATCH 056/158] bundle update to resolve a few console warnings (#2300) * bundle update to resolve a few console warnings * add nocov to resolve flake --- Gemfile.lock | 6 ++++-- app/controllers/concerns/view_component/preview_actions.rb | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6653b25d3..4a0e59df4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,6 +119,7 @@ GEM diff-lcs (1.6.2) docile (1.4.1) drb (2.2.1) + erb (5.0.1) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -215,7 +216,7 @@ GEM puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.1) + rack (3.1.15) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -254,7 +255,8 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.13.1) + rdoc (6.14.0) + erb psych (>= 4.0.0) redis (5.4.0) redis-client (>= 0.22.0) diff --git a/app/controllers/concerns/view_component/preview_actions.rb b/app/controllers/concerns/view_component/preview_actions.rb index c9cf6f83c..72a54dcad 100644 --- a/app/controllers/concerns/view_component/preview_actions.rb +++ b/app/controllers/concerns/view_component/preview_actions.rb @@ -64,7 +64,11 @@ def find_preview if preview @preview = ViewComponent::Preview.find(preview) else + # TODO: This branch is covered in #test_returns_404_when_preview_does_not_exist, + # but Simplecov doesn't always mark it as covered. + # :nocov: raise AbstractController::ActionNotFound, "Component preview '#{params[:path]}' not found." + # :nocov: end end From 670ca1768b7bf714935fc4a4b5e6e9e9a6a542c1 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 20 May 2025 09:46:38 -0600 Subject: [PATCH 057/158] Remove unnecessary filtering of inline templates, add test (#2302) * Remove unnescessary filtering of inline templates, add test case to confirm commented functionality is already present * Fix final line endings --------- Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> --- lib/view_component/compiler.rb | 5 +---- .../inline_sidecar_conflict_component.html.erb | 1 + .../app/components/inline_sidecar_conflict_component.rb | 7 +++++++ test/sandbox/test/rendering_test.rb | 9 +++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 test/sandbox/app/components/inline_sidecar_conflict_component.html.erb create mode 100644 test/sandbox/app/components/inline_sidecar_conflict_component.rb diff --git a/lib/view_component/compiler.rb b/lib/view_component/compiler.rb index 4d46eb2f3..1a5c0c05d 100644 --- a/lib/view_component/compiler.rb +++ b/lib/view_component/compiler.rb @@ -108,10 +108,7 @@ def template_errors errors << "Couldn't find a template file or inline render method for #{@component}." if @templates.empty? - # We currently allow components to have both an inline call method and a template for a variant, with the - # inline call method overriding the template. We should aim to change this in v4 to instead - # raise an error. - @templates.reject(&:inline_call?) + @templates .map { |template| [template.variant, template.format] } .tally .select { |_, count| count > 1 } diff --git a/test/sandbox/app/components/inline_sidecar_conflict_component.html.erb b/test/sandbox/app/components/inline_sidecar_conflict_component.html.erb new file mode 100644 index 000000000..af5626b4a --- /dev/null +++ b/test/sandbox/app/components/inline_sidecar_conflict_component.html.erb @@ -0,0 +1 @@ +Hello, world! diff --git a/test/sandbox/app/components/inline_sidecar_conflict_component.rb b/test/sandbox/app/components/inline_sidecar_conflict_component.rb new file mode 100644 index 000000000..f7ab1253f --- /dev/null +++ b/test/sandbox/app/components/inline_sidecar_conflict_component.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InlineSidecarConflictComponent < ViewComponent::Base + def call + "Hi!" + end +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index cab3f1831..561cd8b64 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -467,6 +467,15 @@ def test_raises_error_when_sidecar_template_is_missing ) end + def test_raises_error_when_inline_and_sidecar_template_conflict + error = + assert_raises ViewComponent::TemplateError do + render_inline(InlineSidecarConflictComponent.new) + end + + assert_includes error.message, "Template file and inline render method found for InlineSidecarConflictComponent." + end + def test_raises_error_when_more_than_one_sidecar_template_is_present error = assert_raises ViewComponent::TemplateError do From 8239991e95f8bf6125682af7458a79015c98520e Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 20 May 2025 11:04:16 -0600 Subject: [PATCH 058/158] add template annotations for inline_call components (#2301) * add template annotations for inline_call components Per @dmarcoux, inline_call components do not render template annotations due to their lack of template. This change adds functionality to mimic Rails' built-in behavior for inline_call components. Closes #1301 * cleanup * omit Rails.root from inline_call identifier --- app/controllers/view_components_system_test_controller.rb | 4 +--- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 5 +++++ test/sandbox/test/rendering_test.rb | 6 ++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/controllers/view_components_system_test_controller.rb b/app/controllers/view_components_system_test_controller.rb index b9ad5f36a..dbca3da7a 100644 --- a/app/controllers/view_components_system_test_controller.rb +++ b/app/controllers/view_components_system_test_controller.rb @@ -19,9 +19,7 @@ def system_test_entrypoint def validate_file_path base_path = ::File.realpath(self.class.temp_dir) @path = ::File.realpath(params.permit(:file)[:file], base_path) - unless @path.start_with?(base_path) - raise ViewComponent::SystemTestControllerNefariousPathError - end + raise ViewComponent::SystemTestControllerNefariousPathError unless @path.start_with?(base_path) end end end diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b5fd6216e..77c7249d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* Add template annotations for components with `def call`. + + *Joel Hawksley* + ## 4.0.0.alpha5 * BREAKING: `config.view_component_path` is now `config.generate.path`, as components have long since been able to exist in any directory. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 49d287920..6591a75d7 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -170,6 +170,11 @@ def render_in(view_context, &block) end end + if ActionView::Base.annotate_rendered_view_with_filenames && current_template.inline_call? && request.format == :html + identifier = defined?(Rails.root) ? self.class.identifier.sub("#{Rails.root}/", "") : self.class.identifier + value = "".html_safe + value + "".html_safe + end + value else "" diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 561cd8b64..57cd781ad 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -131,6 +131,12 @@ def test_render_empty_component end end + def test_render_empty_component_with_annotations + render_inline(EmptyComponent.new) + + assert_includes rendered_content, "empty_component.rb" + end + def test_renders_slim_template render_inline(SlimComponent.new(message: "bar")) { "foo" } From ad269276e308cac468b09410bd7e27cd0e734b6d Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 20 May 2025 11:17:50 -0600 Subject: [PATCH 059/158] [V4] document active scaffold incompatibility (#2303) * document known issues * vale --- docs/known_issues.md | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/docs/known_issues.md b/docs/known_issues.md index dafbc9fdb..f0e600979 100644 --- a/docs/known_issues.md +++ b/docs/known_issues.md @@ -8,25 +8,6 @@ nav_order: 11 _There remain several known issues with ViewComponent. We'd be thrilled to see you consider solutions to these thorny bugs!_ -## Limited i18n support - -ViewComponent currently only supports sidecar translation files. In some cases, it could be useful to support centralized translations using namespacing: - -```yml -en: - view_components: - login_form: - submit: "Log in" - nav: - user_info: - login: "Log in" - logout: "Log out" -``` - -## Lack of Jekyll support - -It would be lovely if we could support rendering ViewComponents in Jekyll, as it would enable the reuse of ViewComponents across static and dynamic (Rails-based) sites. - ## Forms don't use the default `FormBuilder` Calls to form helpers such as `form_with` in ViewComponents [don't use the default form builder](https://github.com/viewcomponent/view_component/pull/1090#issue-753331927). This is by design, as it allows global state to change the rendered output of a component. Instead, consider passing a form builder into form helpers via the `builder` argument: @@ -36,3 +17,7 @@ Calls to form helpers such as `form_with` in ViewComponents [don't use the defau <%= f.text_field :name %> <% end %> ``` + +## Incompatibility with `active_scaffold` + +Due to `active_scaffold`'s [monkey-patching](https://github.com/activescaffold/active_scaffold/blob/0ada8b7a51bf608c4ade18983ce4494c963963f3/lib/active_scaffold/extensions/action_view_rendering.rb) of `render` that hasn't been updated to support renderable objects like ViewComponents, it's impossible to use `active_scaffold` alongside `view_component`. From 3b923b34881f1c30d5333bf8f001869957426b79 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 21 May 2025 09:17:54 -0600 Subject: [PATCH 060/158] Move component_parent_class to generator configuration namespace (#2308) * Move component_parent_class to generator configuration namespace * Update docs/CHANGELOG.md --- docs/CHANGELOG.md | 4 ++++ .../component/component_generator.rb | 2 +- lib/view_component/base.rb | 22 ++++++++++--------- lib/view_component/config.rb | 7 ------ test/test_helper.rb | 2 +- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 77c7249d0..f183ae690 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* BREAKING: `config.component_parent_class` is now `config.generate.component_parent_class`, moving the generator-specific option to the generator configuration namespace. + + *Joel Hawksley* + * Add template annotations for components with `def call`. *Joel Hawksley* diff --git a/lib/generators/view_component/component/component_generator.rb b/lib/generators/view_component/component/component_generator.rb index 9824a346f..f2e3f9491 100644 --- a/lib/generators/view_component/component/component_generator.rb +++ b/lib/generators/view_component/component/component_generator.rb @@ -43,7 +43,7 @@ def create_component_file def parent_class return options[:parent] if options[:parent] - ViewComponent::Base.config.component_parent_class || default_parent_class + ViewComponent::Base.config.generate.component_parent_class || default_parent_class end def initialize_signature? diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index cc8894765..62f0648f0 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -405,16 +405,6 @@ def safe_output_postamble # configured on a per-test basis using `with_controller_class`. # - # Parent class for generated components - # - # ```ruby - # config.view_component.component_parent_class = "MyBaseComponent" - # ``` - # - # Defaults to nil. If this is falsy, generators will use - # "ApplicationComponent" if defined, "ViewComponent::Base" otherwise. - # - # Configuration for generators. # # All options under this namespace default to `false` unless otherwise @@ -472,6 +462,18 @@ def safe_output_postamble # ``` # # Defaults to `false`. + # + # #### component_parent_class + # + # Parent class for generated components + # + # ```ruby + # config.view_component.generate.component_parent_class = "MyBaseComponent" + # ``` + # + # Defaults to nil. If this is falsy, generators will use + # "ApplicationComponent" if defined, "ViewComponent::Base" otherwise. + # class << self # The file path of the component Ruby file. diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index 668eaf8eb..cd9ef9f57 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -16,7 +16,6 @@ def defaults preview_controller: "ViewComponentsController", preview_route: "/rails/view_components", instrumentation_enabled: false, - component_parent_class: nil, show_previews: Rails.env.development? || Rails.env.test?, preview_paths: default_preview_paths, test_controller: "ApplicationController", @@ -115,12 +114,6 @@ def defaults # Whether ActiveSupport notifications are enabled. # Defaults to `false`. - # @!attribute component_parent_class - # @return [String] - # The parent class from which generated components will inherit. - # Defaults to `nil`. If this is falsy, generators will use - # `"ApplicationComponent"` if defined, `"ViewComponent::Base"` otherwise. - # @!attribute show_previews # @return [Boolean] # Whether component previews are enabled. diff --git a/test/test_helper.rb b/test/test_helper.rb index bc4b84061..afb7e9bd6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -98,7 +98,7 @@ def with_custom_component_path(new_value, &block) end def with_custom_component_parent_class(new_value, &block) - with_config_option(:component_parent_class, new_value, &block) + with_generate_option(:component_parent_class, new_value, &block) end def with_application_component_class From bd5b0de11c8e1fef582d16786cc14a062e26298e Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 21 May 2025 13:14:52 -0600 Subject: [PATCH 061/158] remove v4 TODO comment. will revisit when Rails < 8 is EOL (#2312) --- test/sandbox/test/rendering_test.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index fa7838171..b705adf5c 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -351,9 +351,7 @@ def test_renders_component_with_asset_url assert_match(%r{http://assets.example.com/assets/application-\w+.css}, render_inline(component).text) if Rails.version.to_f < 8.0 - # Propshaft doesn't allow setting custom hosts so this only works in Rails < 8 - # TODO: Revisit this comment for v4 to see if we need to make any deprecations component.config.asset_host = nil assert_match(%r{/assets/application-\w+.css}, render_inline(component).text) From c42806ed1b237e7433a6b916775f22482e161b57 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 21 May 2025 15:49:25 -0600 Subject: [PATCH 062/158] move dev dependencies to Gemfile (#2316) * move dev dependencies to Gemfile * bundle update * get appraisal tests green --- Appraisals | 5 ++--- Gemfile | 32 ++++++++++++++++++++++++++++++++ Gemfile.lock | 5 +---- gemfiles/rails_7.1.gemfile | 33 ++++++++++++++++++++++++++++++++- gemfiles/rails_7.2.gemfile | 34 +++++++++++++++++++++++++++++++++- gemfiles/rails_8.0.gemfile | 34 +++++++++++++++++++++++++++++++++- gemfiles/rails_main.gemfile | 32 ++++++++++++++++++++++++++++++++ view_component.gemspec | 32 -------------------------------- 8 files changed, 165 insertions(+), 42 deletions(-) diff --git a/Appraisals b/Appraisals index 3037f6841..0b94d1e66 100644 --- a/Appraisals +++ b/Appraisals @@ -4,19 +4,18 @@ appraise "rails-7.1" do gem "rails", "~> 7.1" gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 1" - gem "sprockets-rails", "~> 3" end appraise "rails-7.2" do gem "rails", "~> 7.2" gem "tailwindcss-rails", "~> 2" - gem "sprockets-rails", "~> 3" + gem "turbo-rails", "~> 2" end appraise "rails-8.0" do gem "rails", "~> 8.0" gem "tailwindcss-rails", "~> 2" - gem "propshaft", "~> 1" + gem "turbo-rails", "~> 2" end appraise "rails-main" do diff --git a/Gemfile b/Gemfile index 22f877f7a..7388add4c 100644 --- a/Gemfile +++ b/Gemfile @@ -9,3 +9,35 @@ gem "rails", (rails_version == "main") ? {git: "https://github.com/rails/rails", ruby_version = (ENV["RUBY_VERSION"] || "~> 3.4").to_s ruby ruby_version + +group :development, :test do + gem "allocation_stats" + gem "appraisal", "~> 2" + gem "benchmark-ips", "~> 2" + gem "better_html" + gem "bundler", "~> 2" + gem "capybara", "~> 3" + gem "cuprite" + gem "erb_lint" + gem "haml", "~> 6" + gem "jbuilder", "~> 2" + gem "m", "~> 1" + gem "method_source", "~> 1" + gem "minitest", "~> 5" + gem "propshaft", "~> 1" + gem "puma", "~> 6" + gem "rake", "~> 13" + gem "rails-dom-testing", "~> 2.3.0" + gem "redis" + gem "rspec-rails", "~> 7" + gem "rubocop-md", "~> 2" + gem "selenium-webdriver", "~> 4" + gem "simplecov-console", "< 1" + gem "simplecov", "< 1" + gem "slim", "~> 5" + gem "sprockets-rails", "~> 3" + gem "standard", "~> 1" + gem "warning" + gem "yard-activesupport-concern", "< 1" + gem "yard", "< 1" +end diff --git a/Gemfile.lock b/Gemfile.lock index 0b13b2c82..52cb9bce4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -354,9 +354,6 @@ GEM thor (1.3.2) tilt (2.6.0) timeout (0.4.3) - turbo-rails (2.0.13) - actionpack (>= 7.1.0) - railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) @@ -405,6 +402,7 @@ DEPENDENCIES propshaft (~> 1) puma (~> 6) rails (~> 8) + rails-dom-testing (~> 2.3.0) rake (~> 13) redis rspec-rails (~> 7) @@ -415,7 +413,6 @@ DEPENDENCIES slim (~> 5) sprockets-rails (~> 3) standard (~> 1) - turbo-rails (~> 2) view_component! warning yard (< 1) diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile index 6cd2db1f4..41fa8ce93 100644 --- a/gemfiles/rails_7.1.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -5,6 +5,37 @@ source "https://rubygems.org" gem "rails", "~> 7.1" gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 1" -gem "sprockets-rails", "~> 3" + +group :development, :test do + gem "allocation_stats" + gem "appraisal", "~> 2" + gem "benchmark-ips", "~> 2" + gem "better_html" + gem "bundler", "~> 2" + gem "capybara", "~> 3" + gem "cuprite" + gem "erb_lint" + gem "haml", "~> 6" + gem "jbuilder", "~> 2" + gem "m", "~> 1" + gem "method_source", "~> 1" + gem "minitest", "~> 5" + gem "propshaft", "~> 1" + gem "puma", "~> 6" + gem "rake", "~> 13" + gem "rails-dom-testing", "~> 2.3.0" + gem "redis" + gem "rspec-rails", "~> 7" + gem "rubocop-md", "~> 2" + gem "selenium-webdriver", "~> 4" + gem "simplecov-console", "< 1" + gem "simplecov", "< 1" + gem "slim", "~> 5" + gem "sprockets-rails", "~> 3" + gem "standard", "~> 1" + gem "warning" + gem "yard-activesupport-concern", "< 1" + gem "yard", "< 1" +end gemspec path: "../" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 18081f2c8..1a7629cbd 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -4,6 +4,38 @@ source "https://rubygems.org" gem "rails", "~> 7.2" gem "tailwindcss-rails", "~> 2" -gem "sprockets-rails", "~> 3" +gem "turbo-rails", "~> 2" + +group :development, :test do + gem "allocation_stats" + gem "appraisal", "~> 2" + gem "benchmark-ips", "~> 2" + gem "better_html" + gem "bundler", "~> 2" + gem "capybara", "~> 3" + gem "cuprite" + gem "erb_lint" + gem "haml", "~> 6" + gem "jbuilder", "~> 2" + gem "m", "~> 1" + gem "method_source", "~> 1" + gem "minitest", "~> 5" + gem "propshaft", "~> 1" + gem "puma", "~> 6" + gem "rake", "~> 13" + gem "rails-dom-testing", "~> 2.3.0" + gem "redis" + gem "rspec-rails", "~> 7" + gem "rubocop-md", "~> 2" + gem "selenium-webdriver", "~> 4" + gem "simplecov-console", "< 1" + gem "simplecov", "< 1" + gem "slim", "~> 5" + gem "sprockets-rails", "~> 3" + gem "standard", "~> 1" + gem "warning" + gem "yard-activesupport-concern", "< 1" + gem "yard", "< 1" +end gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile index 5821b18f8..78f72aab2 100644 --- a/gemfiles/rails_8.0.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -4,6 +4,38 @@ source "https://rubygems.org" gem "rails", "~> 8.0" gem "tailwindcss-rails", "~> 2" -gem "propshaft", "~> 1" +gem "turbo-rails", "~> 2" + +group :development, :test do + gem "allocation_stats" + gem "appraisal", "~> 2" + gem "benchmark-ips", "~> 2" + gem "better_html" + gem "bundler", "~> 2" + gem "capybara", "~> 3" + gem "cuprite" + gem "erb_lint" + gem "haml", "~> 6" + gem "jbuilder", "~> 2" + gem "m", "~> 1" + gem "method_source", "~> 1" + gem "minitest", "~> 5" + gem "propshaft", "~> 1" + gem "puma", "~> 6" + gem "rake", "~> 13" + gem "rails-dom-testing", "~> 2.3.0" + gem "redis" + gem "rspec-rails", "~> 7" + gem "rubocop-md", "~> 2" + gem "selenium-webdriver", "~> 4" + gem "simplecov-console", "< 1" + gem "simplecov", "< 1" + gem "slim", "~> 5" + gem "sprockets-rails", "~> 3" + gem "standard", "~> 1" + gem "warning" + gem "yard-activesupport-concern", "< 1" + gem "yard", "< 1" +end gemspec path: "../" diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index a4418c98b..c43ca8c13 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -7,4 +7,36 @@ gem "rack", git: "https://github.com/rack/rack", ref: "8a4475a9f416a72e5b02bd781 gem "tailwindcss-rails", "~> 2" gem "turbo-rails", "~> 2" +group :development, :test do + gem "allocation_stats" + gem "appraisal", "~> 2" + gem "benchmark-ips", "~> 2" + gem "better_html" + gem "bundler", "~> 2" + gem "capybara", "~> 3" + gem "cuprite" + gem "erb_lint" + gem "haml", "~> 6" + gem "jbuilder", "~> 2" + gem "m", "~> 1" + gem "method_source", "~> 1" + gem "minitest", "~> 5" + gem "propshaft", "~> 1" + gem "puma", "~> 6" + gem "rake", "~> 13" + gem "rails-dom-testing", "~> 2.3.0" + gem "redis" + gem "rspec-rails", "~> 7" + gem "rubocop-md", "~> 2" + gem "selenium-webdriver", "~> 4" + gem "simplecov-console", "< 1" + gem "simplecov", "< 1" + gem "slim", "~> 5" + gem "sprockets-rails", "~> 3" + gem "standard", "~> 1" + gem "warning" + gem "yard-activesupport-concern", "< 1" + gem "yard", "< 1" +end + gemspec path: "../" diff --git a/view_component.gemspec b/view_component.gemspec index 811d270e3..979a0c01e 100644 --- a/view_component.gemspec +++ b/view_component.gemspec @@ -32,36 +32,4 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "activesupport", [">= 7.1.0", "< 8.1"] spec.add_runtime_dependency "concurrent-ruby", "~> 1" - spec.add_development_dependency "allocation_stats" - spec.add_development_dependency "appraisal", "~> 2" - spec.add_development_dependency "benchmark-ips", "~> 2" - spec.add_development_dependency "better_html" - spec.add_development_dependency "bundler", "~> 2" - spec.add_development_dependency "capybara", "~> 3" - spec.add_development_dependency "cuprite" - spec.add_development_dependency "erb_lint" - spec.add_development_dependency "haml", "~> 6" - spec.add_development_dependency "jbuilder", "~> 2" - spec.add_development_dependency "m", "~> 1" - spec.add_development_dependency "method_source", "~> 1" - spec.add_development_dependency "minitest", "~> 5" - spec.add_development_dependency "puma", "~> 6" - spec.add_development_dependency "rake", "~> 13" - spec.add_development_dependency "redis" - spec.add_development_dependency "rspec-rails", "~> 7" - spec.add_development_dependency "rubocop-md", "~> 2" - spec.add_development_dependency "selenium-webdriver", "~> 4" - spec.add_development_dependency "simplecov-console", "< 1" - spec.add_development_dependency "simplecov", "< 1" - spec.add_development_dependency "slim", "~> 5" - spec.add_development_dependency "sprockets-rails", "~> 3" - spec.add_development_dependency "standard", "~> 1" - spec.add_development_dependency "turbo-rails", "~> 2" - spec.add_development_dependency "warning" - spec.add_development_dependency "yard-activesupport-concern", "< 1" - spec.add_development_dependency "yard", "< 1" - - if RUBY_VERSION >= "3.3" - spec.add_development_dependency "propshaft", "~> 1" - end end From 76f425ee896d93e2110e4aa83b42f9972a813068 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 22 May 2025 08:25:33 -0600 Subject: [PATCH 063/158] [v4] BREAKING: Move previews-related configuration to separate namespace (#2317) * BREAKING: Move previews-related configuration (enabled, route, paths, default_layout, controller) to under previews namespace. * Update lib/view_component/config.rb Co-authored-by: Simon Fish --------- Co-authored-by: Simon Fish --- .../view_component/preview_actions.rb | 4 +- docs/CHANGELOG.md | 4 + docs/api.md | 135 +++++++++++++----- docs/guide/previews.md | 12 +- docs/guide/testing.md | 2 +- .../preview/preview_generator.rb | 2 +- lib/view_component/config.rb | 73 +++++----- lib/view_component/engine.rb | 14 +- lib/view_component/preview.rb | 2 +- lib/view_component/test_helpers.rb | 2 +- test/sandbox/config/application.rb | 4 +- test/sandbox/test/config_test.rb | 6 +- test/sandbox/test/integration_test.rb | 6 +- test/test_engine/test/config_test.rb | 8 +- test/test_engine/test_helper.rb | 8 +- test/test_helper.rb | 28 ++-- 16 files changed, 198 insertions(+), 112 deletions(-) diff --git a/app/controllers/concerns/view_component/preview_actions.rb b/app/controllers/concerns/view_component/preview_actions.rb index 72a54dcad..9ff8e48d6 100644 --- a/app/controllers/concerns/view_component/preview_actions.rb +++ b/app/controllers/concerns/view_component/preview_actions.rb @@ -47,7 +47,7 @@ def previews # :doc: def default_preview_layout - ViewComponent::Base.config.default_preview_layout + ViewComponent::Base.config.previews.default_layout end # :doc: @@ -99,7 +99,7 @@ def prepend_application_view_paths end def prepend_preview_examples_view_path - prepend_view_path(ViewComponent::Base.preview_paths) + prepend_view_path(ViewComponent::Base.previews.paths) end end end diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 19e90c65e..46fe512e8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,10 @@ nav_order: 6 *Joel Hawksley* +* BREAKING: Move previews-related configuration (`enabled`, `route`, `paths`, `default_layout`, `controller`) to under `previews` namespace. + + *Joel Hawksley* + * Add template annotations for components with `def call`. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index 041f36eb5..1fd28929c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,15 @@ nav_order: 3 Returns the current config. +### `.identifier` → [String] + +The file path of the component Ruby file. + +### `.new(...)` + +Redefine `new` so we can pre-allocate instance variables to optimize +for Ruby object shapes. + ### `.sidecar_files(extensions)` Find sidecar files for the given extensions. @@ -37,7 +46,7 @@ end Whether trailing whitespace will be stripped before compilation. -### `.with_collection(collection, **args)` +### `.with_collection(collection, spacer_component: nil, **args)` Render a component for each element in a collection ([documentation](/guide/collections)): @@ -73,19 +82,23 @@ Whether `content` has been passed to the component. The current controller. Use sparingly as doing so introduces coupling that inhibits encapsulation & reuse, often making testing difficult. +### `#current_template` + +Returns the value of attribute current_template. + ### `#helpers` → [ActionView::Base] A proxy through which to access helpers. Use sparingly as doing so introduces coupling that inhibits encapsulation & reuse, often making testing difficult. -### `#output_preamble` → [String] - -Optional content to be returned before the rendered template. - ### `#output_postamble` → [String] Optional content to be returned after the rendered template. +### `#output_preamble` → [String] + +Optional content to be returned before the rendered template. + ### `#render?` → [Boolean] Override to determine whether the ViewComponent should render. @@ -142,12 +155,6 @@ so helpers, etc work as expected. ## Configuration -### `.component_parent_class` - -The parent class from which generated components will inherit. -Defaults to `nil`. If this is falsy, generators will use -`"ApplicationComponent"` if defined, `"ViewComponent::Base"` otherwise. - ### `#config` Returns the value of attribute config. @@ -159,12 +166,6 @@ class so that config options remain accessible before the rest of ViewComponent has loaded. Defaults to an instance of ViewComponent::Config with all other documented defaults set. -### `.default_preview_layout` - -A custom default layout used for the previews index page and individual -previews. -Defaults to `nil`. If this is falsy, `"component_preview"` is used. - ### `.generate` The subset of configuration options relating to generators. @@ -172,6 +173,12 @@ The subset of configuration options relating to generators. All options under this namespace default to `false` unless otherwise stated. +#### `#path` + +Where to put generated components. Defaults to `app/components`: + + config.view_component.generate.path = "lib/components" + #### `#sidecar` Always generate a component with a sidecar directory: @@ -184,6 +191,12 @@ Always generate a Stimulus controller alongside the component: config.view_component.generate.stimulus_controller = true +#### `#typescript` + +Generate TypeScript files instead of JavaScript files: + + config.view_component.generate.typescript = true + #### `#locale` Always generate translations file alongside the component: @@ -213,33 +226,54 @@ Path to generate preview: Required when there is more than one path defined in preview_paths. Defaults to `""`. If this is blank, the generator will use -`ViewComponent.config.preview_paths` if defined, +`ViewComponent.config.previews.paths` if defined, `"test/components/previews"` otherwise +#### `#use_component_path_for_rspec_tests` + +Whether to use `config.generate.path` when generating new +RSpec component tests: + + config.view_component.generate.use_component_path_for_rspec_tests = true + +When set to `true`, the generator will use the `path` to +decide where to generate the new RSpec component test. +For example, if the `path` is +`app/views/components`, then the generator will create a new spec file +in `spec/views/components/` rather than the default `spec/components/`. + ### `.instrumentation_enabled` Whether ActiveSupport notifications are enabled. Defaults to `false`. -### `.preview_controller` +### `.previews` + +The subset of configuration options relating to previews. + +#### `#controller` + +The controller used for previewing components. Defaults to `ViewComponentsController`: -The controller used for previewing components. -Defaults to `ViewComponentsController`. + config.view_component.previews.controller = "MyPreviewController" -### `.preview_paths` +#### `#route` -The locations in which component previews will be looked up. -Defaults to `['test/components/previews']` relative to your Rails root. +The entry route for component previews. Defaults to `/rails/view_components`: -### `.preview_route` + config.view_component.previews.route = "/my_previews" -The entry route for component previews. -Defaults to `"/rails/view_components"`. +#### `#enabled` -### `.show_previews` +Whether component previews are enabled. Defaults to `true` in development and test environments: -Whether component previews are enabled. -Defaults to `true` in development and test environments. + config.view_component.previews.enabled = false + +#### `#default_layout` + +A custom default layout used for the previews index page and individual previews. Defaults to `false`: + + config.view_component.previews.default_layout = false ### `.test_controller` @@ -249,7 +283,7 @@ Defaults to `ApplicationController`. ## ViewComponent::TestHelpers -### `#render_in_view_context(*args, &block)` +### `#render_in_view_context(...)` Execute the given block in the view context (using `instance_exec`). Internally sets `page` to be a `Capybara::Node::Simple`, allowing for @@ -263,7 +297,7 @@ end assert_text("Hello, World!") ``` -### `#render_inline(component, **args, &block)` → [Nokogiri::HTML] +### `#render_inline(component, **args, &block)` → [Nokogiri::HTML5] Render a component inline. Internally sets `page` to be a `Capybara::Node::Simple`, allowing for Capybara assertions to be used: @@ -273,7 +307,7 @@ render_inline(MyComponent.new) assert_text("Hello, World!") ``` -### `#render_preview(name, from: __vc_test_helpers_preview_class, params: {})` → [Nokogiri::HTML] +### `#render_preview(name, from: __vc_test_helpers_preview_class, params: {})` → [Nokogiri::HTML5] Render a preview inline. Internally sets `page` to be a `Capybara::Node::Simple`, allowing for Capybara assertions to be used: @@ -294,6 +328,15 @@ In RSpec, `Preview` is appended to `described_class`. Returns the result of a render_inline call. +### `#rendered_json` + +`JSON.parse`-d component output. + +```ruby +render_inline(MyJsonComponent.new) +assert_equal(rendered_json["hello"], "world") +``` + ### `#vc_test_controller` → [ActionController::Base] Access the controller used by `render_inline`: @@ -312,7 +355,7 @@ Access the request used by `render_inline`: ```ruby test "component does not render in Firefox" do - vc_test_request.env["HTTP_USER_AGENT"] = "Mozilla/5.0" + request.env["HTTP_USER_AGENT"] = "Mozilla/5.0" render_inline(NoFirefoxComponent.new) refute_component_rendered end @@ -329,6 +372,16 @@ with_controller_class(UsersController) do end ``` +### `#with_format(*formats)` + +Set format of the current request + +```ruby +with_format(:json) do + render_inline(MyComponent.new) +end +``` + ### `#with_request_url(full_path, host: nil, method: nil)` Set the URL of the current request (such as when using request-dependent path helpers): @@ -355,7 +408,7 @@ with_request_url("/users/42", method: "POST") do end ``` -### `#with_variant(variant)` +### `#with_variant(*variants)` Set the Action Pack request variant for the given block: @@ -387,7 +440,7 @@ To fix this issue, either use the `content` accessor directly or choose a differ ### `ControllerCalledBeforeRenderError` -`#controller` can't be used during initialization, as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. +`#controller` can't be used before rendering, as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. It's sometimes possible to fix this issue by moving code dependent on `#controller` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void). @@ -413,7 +466,7 @@ See [the collections docs](https://viewcomponent.org/guide/collections.html) for ### `HelpersCalledBeforeRenderError` -`#helpers` can't be used during initialization as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. +`#helpers` can't be used before rendering as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. It's sometimes possible to fix this issue by moving code dependent on `#helpers` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void). @@ -439,6 +492,12 @@ A preview template for example EXAMPLE doesn't exist. To fix this issue, create a template for the example. +### `MissingTemplateError` + +No templates for COMPONENT match the request DETAIL. + +To fix this issue, provide a suitable template. + ### `MultipleInlineTemplatesError` Inline templates can only be defined once per-component. @@ -485,7 +544,7 @@ ViewComponent SystemTest controller attempted to load a file outside of the expe ### `TranslateCalledBeforeRenderError` -`#translate` can't be used during initialization as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. +`#translate` can't be used before rendering as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. It's sometimes possible to fix this issue by moving code dependent on `#translate` to a [`#before_render` method](https://viewcomponent.org/api.html#before_render--void). diff --git a/docs/guide/previews.md b/docs/guide/previews.md index c17f0ad59..e17e541d0 100644 --- a/docs/guide/previews.md +++ b/docs/guide/previews.md @@ -119,16 +119,16 @@ Preview classes live in `test/components/previews`, which can be configured usin ```ruby # config/application.rb -config.view_component.preview_paths << "#{Rails.root}/lib/component_previews" +config.view_component.previews.paths << "#{Rails.root}/lib/component_previews" ``` ## Previews route -Previews are served from `/rails/view_components` by default. To use a different endpoint, set the `preview_route` option: +Previews are served from `/rails/view_components` by default. To use a different endpoint, set the `previews.route` option: ```ruby # config/application.rb -config.view_component.preview_route = "/previews" +config.view_component.previews.route = "/previews" ``` ## Preview templates @@ -155,7 +155,7 @@ end ``` To use a different location for preview templates, pass the `template` argument: -(the path should be relative to `config.view_component.preview_paths`): +(the path should be relative to `config.view_component.previews.paths`): ```ruby # test/components/previews/cell_component_preview.rb @@ -184,11 +184,11 @@ Which enables passing in a value: `/rails/view_components/cell_component/default ## Configuring preview controller -Extend previews to add authentication, authorization, before actions, etc. using the `preview_controller` option: +Extend previews to add authentication, authorization, before actions, etc. using the `previews.controller` option: ```ruby # config/application.rb -config.view_component.preview_controller = "MyPreviewController" +config.view_component.previews.controller = "MyPreviewController" ``` Then include `PreviewActions` in the controller: diff --git a/docs/guide/testing.md b/docs/guide/testing.md index cf344c123..0f1e76537 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -304,7 +304,7 @@ To use component previews: ```ruby # config/application.rb -config.view_component.preview_paths << "#{Rails.root}/spec/components/previews" +config.view_component.previews.paths << "#{Rails.root}/spec/components/previews" ``` ## Component system tests diff --git a/lib/generators/view_component/preview/preview_generator.rb b/lib/generators/view_component/preview/preview_generator.rb index dbaf3a261..0997f4c20 100644 --- a/lib/generators/view_component/preview/preview_generator.rb +++ b/lib/generators/view_component/preview/preview_generator.rb @@ -10,7 +10,7 @@ class PreviewGenerator < ::Rails::Generators::NamedBase check_class_collision suffix: "ComponentPreview" def create_preview_file - preview_paths = ViewComponent::Base.config.preview_paths + preview_paths = ViewComponent::Base.config.previews.paths optional_path = options[:preview_path] return if preview_paths.count > 1 && optional_path.blank? diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index cd9ef9f57..9f47e3d3a 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -13,13 +13,9 @@ class << self def defaults ActiveSupport::OrderedOptions.new.merge!({ generate: default_generate_options, - preview_controller: "ViewComponentsController", - preview_route: "/rails/view_components", + previews: default_previews_options, instrumentation_enabled: false, - show_previews: Rails.env.development? || Rails.env.test?, - preview_paths: default_preview_paths, - test_controller: "ApplicationController", - default_preview_layout: nil + test_controller: "ApplicationController" }) end @@ -83,7 +79,7 @@ def defaults # # Required when there is more than one path defined in preview_paths. # Defaults to `""`. If this is blank, the generator will use - # `ViewComponent.config.preview_paths` if defined, + # `ViewComponent.config.previews.paths` if defined, # `"test/components/previews"` otherwise # # #### `#use_component_path_for_rspec_tests` @@ -99,43 +95,46 @@ def defaults # `app/views/components`, then the generator will create a new spec file # in `spec/views/components/` rather than the default `spec/components/`. - # @!attribute preview_controller - # @return [String] - # The controller used for previewing components. - # Defaults to `ViewComponentsController`. - - # @!attribute preview_route - # @return [String] - # The entry route for component previews. - # Defaults to `"/rails/view_components"`. + # @!attribute previews + # @return [ActiveSupport::OrderedOptions] + # The subset of configuration options relating to previews. + # + # #### `#controller` + # + # The controller used for previewing components. Defaults to `ViewComponentsController`: + # + # config.view_component.previews.controller = "MyPreviewController" + # + # #### `#route` + # + # The entry route for component previews. Defaults to `/rails/view_components`: + # + # config.view_component.previews.route = "/my_previews" + # + # #### `#enabled` + # + # Whether component previews are enabled. Defaults to `true` in development and test environments: + # + # config.view_component.previews.enabled = false + # + # #### `#default_layout` + # + # A custom default layout used for the previews index page and individual previews. Defaults to `false`: + # + # config.view_component.previews.default_layout = false + # # @!attribute instrumentation_enabled # @return [Boolean] # Whether ActiveSupport notifications are enabled. # Defaults to `false`. - # @!attribute show_previews - # @return [Boolean] - # Whether component previews are enabled. - # Defaults to `true` in development and test environments. - - # @!attribute preview_paths - # @return [Array] - # The locations in which component previews will be looked up. - # Defaults to `['test/components/previews']` relative to your Rails root. - # @!attribute test_controller # @return [String] # The controller used for testing components. # Can also be configured on a per-test basis using `#with_controller_class`. # Defaults to `ApplicationController`. - # @!attribute default_preview_layout - # @return [String] - # A custom default layout used for the previews index page and individual - # previews. - # Defaults to `nil`. If this is falsy, `"component_preview"` is used. - def default_preview_paths (default_rails_preview_paths + default_rails_engines_preview_paths).uniq end @@ -166,6 +165,16 @@ def default_generate_options options.path = "app/components" options end + + def default_previews_options + options = ActiveSupport::OrderedOptions.new + options.controller = "ViewComponentsController" + options.route = "/rails/view_components" + options.enabled = Rails.env.development? || Rails.env.test? + options.default_layout = false + options.paths = default_preview_paths + options + end end # @!attribute current diff --git a/lib/view_component/engine.rb b/lib/view_component/engine.rb index 8143d2ac0..bb23d4888 100644 --- a/lib/view_component/engine.rb +++ b/lib/view_component/engine.rb @@ -11,7 +11,7 @@ class Engine < Rails::Engine # :nodoc: initializer "view_component.set_configs" do |app| options = app.config.view_component - %i[generate preview_controller preview_route].each do |config_option| + %i[generate previews].each do |config_option| options[config_option] ||= ViewComponent::Base.public_send(config_option) end options.instrumentation_enabled = false if options.instrumentation_enabled.nil? @@ -19,7 +19,7 @@ class Engine < Rails::Engine # :nodoc: if options.show_previews # This is still necessary because when `config.view_component` is declared, `Rails.root` is unspecified. - options.preview_paths << "#{Rails.root}/test/components/previews" if defined?(Rails.root) && Dir.exist?( + options.previews.paths << "#{Rails.root}/test/components/previews" if defined?(Rails.root) && Dir.exist?( "#{Rails.root}/test/components/previews" ) end @@ -36,8 +36,8 @@ class Engine < Rails::Engine # :nodoc: initializer "view_component.set_autoload_paths" do |app| options = app.config.view_component - if options.show_previews && !options.preview_paths.empty? - paths_to_add = options.preview_paths - ActiveSupport::Dependencies.autoload_paths + if options.show_previews && !options.previews.paths.empty? + paths_to_add = options.previews.paths - ActiveSupport::Dependencies.autoload_paths ActiveSupport::Dependencies.autoload_paths.concat(paths_to_add) if paths_to_add.any? end end @@ -90,17 +90,17 @@ class Engine < Rails::Engine # :nodoc: if options.show_previews app.routes.prepend do - preview_controller = options.preview_controller.sub(/Controller$/, "").underscore + preview_controller = options.previews.controller.sub(/Controller$/, "").underscore get( - options.preview_route, + options.previews.route, to: "#{preview_controller}#index", as: :preview_view_components, internal: true ) get( - "#{options.preview_route}/*path", + "#{options.previews.route}/*path", to: "#{preview_controller}#previews", as: :preview_view_component, internal: true diff --git a/lib/view_component/preview.rb b/lib/view_component/preview.rb index f2984a402..64a1c0885 100644 --- a/lib/view_component/preview.rb +++ b/lib/view_component/preview.rb @@ -102,7 +102,7 @@ def __vc_load_previews private def preview_paths - Base.preview_paths + Base.previews.paths end end end diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 0a9cb8847..6465068e5 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -73,7 +73,7 @@ def rendered_json # @param params [Hash] Parameters to be passed to the preview. # @return [Nokogiri::HTML5] def render_preview(name, from: __vc_test_helpers_preview_class, params: {}) - previews_controller = __vc_test_helpers_build_controller(Rails.application.config.view_component.preview_controller.constantize) + previews_controller = __vc_test_helpers_build_controller(Rails.application.config.view_component.previews.controller.constantize) # From what I can tell, it's not possible to overwrite all request parameters # at once, so we set them individually here. diff --git a/test/sandbox/config/application.rb b/test/sandbox/config/application.rb index ab733342d..d008f4754 100644 --- a/test/sandbox/config/application.rb +++ b/test/sandbox/config/application.rb @@ -41,8 +41,8 @@ class Application < Rails::Application # Prepare test_set_no_duplicate_autoload_paths config.autoload_paths.push("#{config.root}/my/components/previews") - config.view_component.preview_paths << "#{config.root}/my/components/previews" - config.view_component.preview_paths << "#{Rails.root}/lib/component_previews" + config.view_component.previews.paths << "#{config.root}/my/components/previews" + config.view_component.previews.paths << "#{Rails.root}/lib/component_previews" end end diff --git a/test/sandbox/test/config_test.rb b/test/sandbox/test/config_test.rb index e67c04119..778b9ca6c 100644 --- a/test/sandbox/test/config_test.rb +++ b/test/sandbox/test/config_test.rb @@ -10,10 +10,10 @@ def setup def test_defaults_are_correct assert_equal @config.generate, {preview_path: "", path: "app/components"} - assert_equal @config.preview_controller, "ViewComponentsController" - assert_equal @config.preview_route, "/rails/view_components" + assert_equal @config.previews.controller, "ViewComponentsController" + assert_equal @config.previews.route, "/rails/view_components" assert_equal @config.instrumentation_enabled, false - assert_equal @config.preview_paths, ["#{Rails.root}/test/components/previews"] + assert_equal @config.previews.paths, ["#{Rails.root}/test/components/previews"] end def test_all_methods_are_documented diff --git a/test/sandbox/test/integration_test.rb b/test/sandbox/test/integration_test.rb index 2244ab4fd..81bb57eac 100644 --- a/test/sandbox/test/integration_test.rb +++ b/test/sandbox/test/integration_test.rb @@ -649,8 +649,10 @@ def test_config_options_shared_between_base_and_engine config_entrypoints.first.yield_self do |config| { generate: config.generate.dup.tap { |c| c.sidecar = true }, - preview_controller: "SomeOtherController", - preview_route: "/some/other/route" + previews: config.previews.dup.tap { |c| + c.controller = "SomeOtherController" + c.route = "/some/other/route" + } }.each do |option, value| with_config_option(option, value, config_entrypoint: config) do assert_equal(config.public_send(option), config_entrypoints.second.public_send(option)) diff --git a/test/test_engine/test/config_test.rb b/test/test_engine/test/config_test.rb index 6bcaf572e..00fe47a3f 100644 --- a/test/test_engine/test/config_test.rb +++ b/test/test_engine/test/config_test.rb @@ -10,11 +10,11 @@ def setup def test_defaults_are_correct assert_equal @config.generate, {preview_path: "", path: "app/components"} - assert_equal @config.preview_controller, "ViewComponentsController" - assert_equal @config.preview_route, "/rails/view_components" + assert_equal @config.previews.controller, "ViewComponentsController" + assert_equal @config.previews.route, "/rails/view_components" assert_equal @config.instrumentation_enabled, false - assert_equal @config.show_previews, true - assert_equal @config.preview_paths, ["#{TestEngine::Engine.root}/test/components/previews"] + assert_equal @config.previews.enabled, true + assert_equal @config.previews.paths, ["#{TestEngine::Engine.root}/test/components/previews"] end end end diff --git a/test/test_engine/test_helper.rb b/test/test_engine/test_helper.rb index 4dde4ed40..355531845 100644 --- a/test/test_engine/test_helper.rb +++ b/test/test_engine/test_helper.rb @@ -30,6 +30,10 @@ def with_config_option(option_name, new_value, config_entrypoint: TestEngine::En config_entrypoint.public_send(:"#{option_name}=", old_value) end -def with_preview_paths(new_value, &block) - with_config_option(:preview_paths, new_value, &block) +def with_preview_paths(new_value, config_entrypoint: TestEngine::Engine.config.view_component, &block) + old_value = config_entrypoint.previews.paths + config_entrypoint.previews.paths = new_value + yield +ensure + config_entrypoint.previews.paths = old_value end diff --git a/test/test_helper.rb b/test/test_helper.rb index afb7e9bd6..b297dc911 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -70,26 +70,26 @@ def with_config_option(option_name, new_value, config_entrypoint: Rails.applicat # @yield Test code to run # @return [void] def with_preview_paths(new_value, &block) - with_config_option(:preview_paths, new_value, &block) + with_previews_option(:paths, new_value, &block) end -def with_preview_route(new_value) - old_value = Rails.application.config.view_component.preview_route - Rails.application.config.view_component.preview_route = new_value +def with_preview_route(new_value, &block) + old_value = Rails.application.config.view_component.previews.route + Rails.application.config.view_component.previews.route = new_value app.reloader.reload! yield ensure - Rails.application.config.view_component.preview_route = old_value + Rails.application.config.view_component.previews.route = old_value app.reloader.reload! end -def with_preview_controller(new_value) - old_value = Rails.application.config.view_component.preview_controller - Rails.application.config.view_component.preview_controller = new_value +def with_preview_controller(new_value, &block) + old_value = Rails.application.config.view_component.previews.controller + Rails.application.config.view_component.previews.controller = new_value app.reloader.reload! yield ensure - Rails.application.config.view_component.preview_controller = old_value + Rails.application.config.view_component.previews.controller = old_value app.reloader.reload! end @@ -116,6 +116,14 @@ def with_generate_option(config_option, value) Rails.application.config.view_component.generate[config_option] = old_value end +def with_previews_option(config_option, value) + old_value = Rails.application.config.view_component.previews[config_option] + Rails.application.config.view_component.previews[config_option] = value + yield +ensure + Rails.application.config.view_component.previews[config_option] = old_value +end + def with_instrumentation_enabled_option(value) old_value = Rails.application.config.view_component.instrumentation_enabled Rails.application.config.view_component.instrumentation_enabled = value @@ -172,7 +180,7 @@ def modify_file(file, content) end def with_default_preview_layout(layout, &block) - with_config_option(:default_preview_layout, layout, &block) + with_previews_option(:default_layout, layout, &block) end def with_compiler_development_mode(mode) From ab0b9262550481beb5c6592efabefc0b8940ae45 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Thu, 22 May 2025 23:57:00 +0930 Subject: [PATCH 064/158] Support for Turbo::StreamsHelper in ViewComponent::Base (#2310) --- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 3 +++ test/sandbox/app/components/turbo_stream_component.html.erb | 2 +- test/sandbox/app/components/turbo_stream_component.rb | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 46fe512e8..c8a7a4c48 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,10 @@ nav_order: 6 *Joel Hawksley* +* Add support for including Turbo::StreamsHelper + + *Stephen Nelson* + ## 4.0.0.alpha5 * BREAKING: `config.view_component_path` is now `config.generate.path`, as components have long since been able to exist in any directory. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index dddb4d303..6eb5edbe9 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -95,6 +95,9 @@ def __vc_pre_allocate_instance_variables # HTML construction methods delegate :output_buffer, :lookup_context, :view_renderer, :view_flow, to: :helpers + # For Turbo::StreamsHelper + delegate :formats, :formats=, to: :helpers + # For Content Security Policy nonces delegate :content_security_policy_nonce, to: :helpers diff --git a/test/sandbox/app/components/turbo_stream_component.html.erb b/test/sandbox/app/components/turbo_stream_component.html.erb index bfcb1ae0e..bf6c3ea64 100644 --- a/test/sandbox/app/components/turbo_stream_component.html.erb +++ b/test/sandbox/app/components/turbo_stream_component.html.erb @@ -1 +1 @@ -<%= helpers.turbo_stream.update 'area1' do %>Hello, world!<% end %> +<%= turbo_stream.update 'area1' do %>Hello, world!<% end %> diff --git a/test/sandbox/app/components/turbo_stream_component.rb b/test/sandbox/app/components/turbo_stream_component.rb index c284b3dcf..66424cdf4 100644 --- a/test/sandbox/app/components/turbo_stream_component.rb +++ b/test/sandbox/app/components/turbo_stream_component.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true class TurboStreamComponent < ViewComponent::Base + include Turbo::StreamsHelper end From 9dbe441e2053a6ed10d021cbce31bdafde12a1e4 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 22 May 2025 08:31:51 -0600 Subject: [PATCH 065/158] fix changelog typo --- docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c8a7a4c48..2c35fbb0a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,7 +22,7 @@ nav_order: 6 *Joel Hawksley* -* Add support for including Turbo::StreamsHelper +* Add support for including Turbo::StreamsHelper. *Stephen Nelson* From c57e974d311d681c0778b8e51e6b92f652507c44 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 22 May 2025 14:44:24 -0600 Subject: [PATCH 066/158] Add test case for component that only renders a variant (#2323) Closes #1956 --- .../app/components/only_variant_component.html+phone.erb | 1 + test/sandbox/app/components/only_variant_component.rb | 4 ++++ test/sandbox/test/rendering_test.rb | 8 ++++++++ 3 files changed, 13 insertions(+) create mode 100644 test/sandbox/app/components/only_variant_component.html+phone.erb create mode 100644 test/sandbox/app/components/only_variant_component.rb diff --git a/test/sandbox/app/components/only_variant_component.html+phone.erb b/test/sandbox/app/components/only_variant_component.html+phone.erb new file mode 100644 index 000000000..a766c50a5 --- /dev/null +++ b/test/sandbox/app/components/only_variant_component.html+phone.erb @@ -0,0 +1 @@ +Phone diff --git a/test/sandbox/app/components/only_variant_component.rb b/test/sandbox/app/components/only_variant_component.rb new file mode 100644 index 000000000..5a3fe8fd5 --- /dev/null +++ b/test/sandbox/app/components/only_variant_component.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class OnlyVariantComponent < ViewComponent::Base +end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index b705adf5c..47bcc9c65 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -218,6 +218,14 @@ def test_renders_component_with_variant end end + def test_renders_component_with_only_variant + with_variant :phone do + render_inline(OnlyVariantComponent.new) + + assert_text("Phone") + end + end + def test_renders_component_with_multiple_variants with_variant :app, :phone do render_inline(VariantsComponent.new) From 1a06fe3f6936aaaaeec448f6ecc10cf7edcdb71d Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 2 Jun 2025 13:48:17 -0600 Subject: [PATCH 067/158] fix allocations --- test/sandbox/test/rendering_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index db66dd494..47bcc9c65 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -30,7 +30,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 75, "3.4" => 78, "3.3" => 82, "3.2" => 81}) do + assert_allocations({"3.5" => 75, "3.4" => 80, "3.3" => 82, "3.2" => 81}) do render_inline(MyComponent.new) end end From 70cdb97d38a23605268bb663557d7c465847bd2c Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 3 Jun 2025 11:59:37 -0600 Subject: [PATCH 068/158] Remove .test_controller in favor of vc_test_controller_class (#2322) * Remove .test_controller in favor of vc_test_controller_class ViewComponent tests should not be beholden to application configuration, but instead should be configured in the test setup. I've removed `config.test_controller` and added `vc_test_controller_class` as such. * allocations --- docs/CHANGELOG.md | 4 ++++ docs/api.md | 10 ++++++++++ docs/guide/testing.md | 14 ++++++++++++-- lib/view_component/base.rb | 10 ---------- lib/view_component/config.rb | 3 +-- lib/view_component/test_helpers.rb | 15 ++++++++++++++- test/sandbox/config/environments/test.rb | 1 - test/sandbox/test/base_test.rb | 15 +++++++-------- test/sandbox/test/rendering_test.rb | 6 +++++- 9 files changed, 53 insertions(+), 25 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f748e061c..851c4ab0e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* BREAKING: Remove `config.test_controller` in favor of `vc_test_controller_class` test helper method. + + *Joel Hawksley* + * BREAKING: `config.component_parent_class` is now `config.generate.component_parent_class`, moving the generator-specific option to the generator configuration namespace. *Joel Hawksley* diff --git a/docs/api.md b/docs/api.md index 1fd28929c..4a3f65f1c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -349,6 +349,16 @@ test "logged out user sees login link" do end ``` +### `#vc_test_controller_class` + +Set the controller used by `render_inline`: + +```ruby +def vc_test_controller_class + MyTestController +end +``` + ### `#vc_test_request` → [ActionDispatch::TestRequest] Access the request used by `render_inline`: diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 0f1e76537..1330a109a 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -169,10 +169,20 @@ end Since 2.27.0 {: .label } -Component tests assume the existence of an `ApplicationController` class, which can be configured globally using the `test_controller` option: +Component tests assume the existence of an `ApplicationController` class. To set the controller for a test file, define `vc_test_controller_class`: ```ruby -config.view_component.test_controller = "BaseController" +class ExampleComponentTest < ViewComponent::TestCase + def vc_test_controller_class + PublicController + end + + def test_component_in_public_controller + render_inline ExampleComponent.new + + assert_text "foo" + end +end ``` To configure the controller used for a test case, use `with_controller_class` from `ViewComponent::TestHelpers`. diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 6eb5edbe9..6c96f6bd5 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -400,16 +400,6 @@ def safe_output_postamble end end - # Set the controller used for testing components: - # - # ```ruby - # config.view_component.test_controller = "MyTestController" - # ``` - # - # Defaults to `nil`. If this is falsy, `"ApplicationController"` is used. Can also be - # configured on a per-test basis using `with_controller_class`. - # - # Configuration for generators. # # All options under this namespace default to `false` unless otherwise diff --git a/lib/view_component/config.rb b/lib/view_component/config.rb index 9f47e3d3a..beedf0b9e 100644 --- a/lib/view_component/config.rb +++ b/lib/view_component/config.rb @@ -14,8 +14,7 @@ def defaults ActiveSupport::OrderedOptions.new.merge!({ generate: default_generate_options, previews: default_previews_options, - instrumentation_enabled: false, - test_controller: "ApplicationController" + instrumentation_enabled: false }) end diff --git a/lib/view_component/test_helpers.rb b/lib/view_component/test_helpers.rb index 6465068e5..60a08e0d7 100644 --- a/lib/view_component/test_helpers.rb +++ b/lib/view_component/test_helpers.rb @@ -233,7 +233,20 @@ def with_request_url(full_path, host: nil, method: nil) # # @return [ActionController::Base] def vc_test_controller - @vc_test_controller ||= __vc_test_helpers_build_controller(Base.test_controller.constantize) + @vc_test_controller ||= __vc_test_helpers_build_controller(vc_test_controller_class) + end + + # Set the controller used by `render_inline`: + # + # ```ruby + # def vc_test_controller_class + # MyTestController + # end + # ``` + def vc_test_controller_class + return @__vc_test_controller_class if defined?(@__vc_test_controller_class) + + defined?(ApplicationController) ? ApplicationController : ActionController::Base end # Access the request used by `render_inline`: diff --git a/test/sandbox/config/environments/test.rb b/test/sandbox/config/environments/test.rb index 12d0e1ef5..ed8a0bbbf 100644 --- a/test/sandbox/config/environments/test.rb +++ b/test/sandbox/config/environments/test.rb @@ -30,7 +30,6 @@ config.view_component.show_previews = true config.view_component.instrumentation_enabled = true - config.view_component.test_controller = "IntegrationExamplesController" # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the diff --git a/test/sandbox/test/base_test.rb b/test/sandbox/test/base_test.rb index bc1b7887f..a9c0e2552 100644 --- a/test/sandbox/test/base_test.rb +++ b/test/sandbox/test/base_test.rb @@ -157,7 +157,7 @@ module TestModuleWithConfig include ViewComponent::Configurable configure do |config| - config.view_component.test_controller = "AnotherController" + config.view_component.instrumentation_enabled = false end class SomeComponent < ViewComponent::Base @@ -169,7 +169,7 @@ module TestAlreadyConfigurableModule include ViewComponent::Configurable configure do |config| - config.view_component.test_controller = "AnotherController" + config.view_component.instrumentation_enabled = false end class SomeComponent < ViewComponent::Base @@ -180,7 +180,7 @@ module TestAlreadyConfiguredModule include ActiveSupport::Configurable configure do |config| - config.view_component = ActiveSupport::InheritableOptions[test_controller: "AnotherController"] + config.view_component = ActiveSupport::InheritableOptions[instrumentation_enabled: false] end include ViewComponent::Configurable @@ -190,10 +190,9 @@ class SomeComponent < ViewComponent::Base end def test_uses_module_configuration - # We override this ourselves in test/sandbox/config/environments/test.rb. - assert_equal "IntegrationExamplesController", TestModuleWithoutConfig::SomeComponent.test_controller - assert_equal "AnotherController", TestModuleWithConfig::SomeComponent.test_controller - assert_equal "AnotherController", TestAlreadyConfigurableModule::SomeComponent.test_controller - assert_equal "AnotherController", TestAlreadyConfiguredModule::SomeComponent.test_controller + assert_equal true, TestModuleWithoutConfig::SomeComponent.instrumentation_enabled + assert_equal false, TestModuleWithConfig::SomeComponent.instrumentation_enabled + assert_equal false, TestAlreadyConfigurableModule::SomeComponent.instrumentation_enabled + assert_equal false, TestAlreadyConfiguredModule::SomeComponent.instrumentation_enabled end end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 47bcc9c65..faf28d58f 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -17,6 +17,10 @@ def __allocate_instance_variables @vc_test_request = nil end + def vc_test_controller_class + IntegrationExamplesController + end + def test_render_inline render_inline(MyComponent.new) @@ -30,7 +34,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 75, "3.4" => 80, "3.3" => 82, "3.2" => 81}) do + assert_allocations({"3.5" => 70, "3.4" => 75, "3.3" => 76, "3.2" => 75}) do render_inline(MyComponent.new) end end From 4ddedab7cabed29fe96d742146f391704e2e60bc Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 3 Jun 2025 15:06:57 -0600 Subject: [PATCH 069/158] Update benchmark to be more representative, update docs on performance, remove object shapes optimization (#2329) * update performance docs to reference more representative benchmark * Revert "Pre-allocate instance variables for better compatibility with Object Shapes (#2282)" This reverts commit afbac9b11cd7e6824b944812a765838ff85a3ee9. * lints * more lints --- docs/CHANGELOG.md | 16 +++-- docs/guide/instrumentation.md | 2 + docs/index.md | 24 ++----- lib/view_component/base.rb | 66 ++++++------------- lib/view_component/inline_template.rb | 6 +- lib/view_component/slot.rb | 25 +++---- lib/view_component/slotable.rb | 4 +- lib/view_component/slotable_default.rb | 18 ----- .../components/complex_component.html.erb | 5 -- performance/components/complex_component.rb | 8 --- performance/components/name_component.rb | 3 - .../nested_complex_component.html.erb | 1 - .../components/nested_complex_component.rb | 10 --- .../components/nested_name_component.rb | 3 - performance/partial_benchmark.rb | 9 +-- .../app/components/object_shapes_component.rb | 14 ---- test/sandbox/test/rendering_test.rb | 28 +------- test/sandbox/test/slotable_test.rb | 24 ------- test/test_helper.rb | 2 - 19 files changed, 58 insertions(+), 210 deletions(-) delete mode 100644 lib/view_component/slotable_default.rb delete mode 100644 performance/components/complex_component.html.erb delete mode 100644 performance/components/complex_component.rb delete mode 100644 performance/components/nested_complex_component.html.erb delete mode 100644 performance/components/nested_complex_component.rb delete mode 100644 test/sandbox/app/components/object_shapes_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 851c4ab0e..0d3109019 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -30,6 +30,18 @@ nav_order: 6 *Stephen Nelson* +* Update documentation on performance to reflect more representative benchmark showing 2-3x speed increase over partials. + + *Joel Hawksley* + +* Add documentation note about instrumentation negatively affecting performance. + + *Joel Hawksley* + +* Revert object shapes optimization due to lack of evidence of improvement. + + *Joel Hawksley* + ## 4.0.0.alpha5 * BREAKING: `config.view_component_path` is now `config.generate.path`, as components have long since been able to exist in any directory. @@ -40,10 +52,6 @@ nav_order: 6 *Joel Hawksley* -* Add internal optimization for Ruby object shapes. - - *Adam Hess*, *Joel Hawksley* - ## 4.0.0.alpha4 * BREAKING: Remove default initializer from `ViewComponent::Base`. Previously, `ViewComponent::Base` defined a catch-all initializer that allowed components without an initializer defined to be passed arbitrary arguments. diff --git a/docs/guide/instrumentation.md b/docs/guide/instrumentation.md index af533ceac..9ff6fb976 100644 --- a/docs/guide/instrumentation.md +++ b/docs/guide/instrumentation.md @@ -26,6 +26,8 @@ ActiveSupport::Notifications.subscribe("render.view_component") do |event| # or end ``` +_Note: Enabling instrumentation negatively impacts the performance of ViewComponent._ + ## Viewing instrumentation sums in the browser developer tools When using `render.view_component` with `config.server_timing = true` (default in development) in Rails 7, the browser developer tools display the sum total timing information in Network > Timing under the key `render.view_component`. diff --git a/docs/index.md b/docs/index.md index af1110d71..11b13ce51 100644 --- a/docs/index.md +++ b/docs/index.md @@ -85,26 +85,12 @@ ViewComponents use a standard Ruby initializer that clearly defines what's neede ### Performance -Based on several [benchmarks](https://github.com/viewcomponent/view_component/blob/main/performance/partial_benchmark.rb), ViewComponents are ~10x faster than partials in real-world use-cases. +Based on several [benchmarks](https://github.com/viewcomponent/view_component/blob/main/performance/partial_benchmark.rb), ViewComponents are ~2.5x faster than partials: -The primary optimization is pre-compiling all ViewComponent templates at application boot, instead of at runtime like traditional Rails views. - -For example, the `MessageComponent` template is compiled onto the Ruby object: - -```ruby -# app/components/message_component.rb -class MessageComponent < ViewComponent::Base - def initialize(name:) - @name = name - end - - def call - @output_buffer.safe_append = "

Hello, ".freeze - @output_buffer.append = (@name) - @output_buffer.safe_append = "!

".freeze - @output_buffer.to_s - end -end +```console +Comparison: + component: 6498.1 i/s + partial: 2676.5 i/s - 2.50x slower ``` ### Code quality diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 6c96f6bd5..954093648 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -34,15 +34,6 @@ class Base class << self delegate(*ViewComponent::Config.defaults.keys, to: :config) - # Redefine `new` so we can pre-allocate instance variables to optimize - # for Ruby object shapes. - def new(...) - instance = allocate - instance.__vc_pre_allocate_instance_variables - instance.send(:initialize, ...) - instance - end - # Returns the current config. # # @return [ActiveSupport::OrderedOptions] @@ -56,29 +47,6 @@ def config end end - def __vc_pre_allocate_instance_variables - @__vc_parent_render_level = 0 - @__vc_set_slots = {} - @__vc_content_evaluated = false - @current_template = nil - @output_buffer = nil - @lookup_context = nil - @view_flow = nil - @view_context = nil - @virtual_path = nil - @__vc_ancestor_calls = nil - @__vc_controller = nil - @__vc_content = :unset # some behaviors depend on checking for nil - @__vc_content_set_by_with_content = nil - @__vc_helpers = nil - @__vc_inline_template = nil - @__vc_inline_template_defined = nil - @__vc_render_in_block = nil - @__vc_request = nil - @__vc_requested_details = nil - @__vc_original_view_context = nil - end - include ActionView::Helpers include ERB::Escape include ActiveSupport::CoreExt::ERBUtil @@ -151,12 +119,14 @@ def render_in(view_context, &block) @__vc_requested_details ||= @lookup_context.vc_requested_details # For caching, such as #cache_if + @current_template = nil unless defined?(@current_template) old_current_template = @current_template - if block && __vc_content_set_by_with_content? + if block && defined?(@__vc_content_set_by_with_content) raise DuplicateContentError.new(self.class.name) end + @__vc_content_evaluated = false @__vc_render_in_block = block before_render @@ -215,12 +185,16 @@ def render_parent # # When rendering the parent inside an .erb template, use `#render_parent` instead. def render_parent_to_string - target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] - @__vc_parent_render_level += 1 + @__vc_parent_render_level ||= 0 # ensure a good starting value - target_render.bind_call(self, @__vc_requested_details) - ensure - @__vc_parent_render_level -= 1 + begin + target_render = self.class.instance_variable_get(:@__vc_ancestor_calls)[@__vc_parent_render_level] + @__vc_parent_render_level += 1 + + target_render.bind_call(self, @__vc_requested_details) + ensure + @__vc_parent_render_level -= 1 + end end # Optional content to be returned before the rendered template. @@ -343,12 +317,12 @@ def __vc_request # @return [String] def content @__vc_content_evaluated = true - return @__vc_content if @__vc_content != :unset + return @__vc_content if defined?(@__vc_content) @__vc_content = if __vc_render_in_block_provided? view_context.capture(self, &@__vc_render_in_block) - elsif __vc_content_set_by_with_content? + elsif __vc_content_set_by_with_content_defined? @__vc_content_set_by_with_content end end @@ -357,7 +331,7 @@ def content # # @return [Boolean] def content? - __vc_render_in_block_provided? || __vc_content_set_by_with_content? + __vc_render_in_block_provided? || __vc_content_set_by_with_content_defined? end private @@ -365,15 +339,15 @@ def content? attr_reader :view_context def __vc_render_in_block_provided? - @view_context && @__vc_render_in_block + defined?(@view_context) && @view_context && @__vc_render_in_block end - def __vc_content_set_by_with_content? - !@__vc_content_set_by_with_content.nil? + def __vc_content_set_by_with_content_defined? + defined?(@__vc_content_set_by_with_content) end def content_evaluated? - @__vc_content_evaluated + defined?(@__vc_content_evaluated) && @__vc_content_evaluated end def maybe_escape_html(text) @@ -577,7 +551,7 @@ def render_template_for(requested_details) child.with_collection_parameter provided_collection_parameter if instance_methods(false).include?(:render_template_for) - vc_ancestor_calls = (!@__vc_ancestor_calls.nil?) ? @__vc_ancestor_calls.dup : [] + vc_ancestor_calls = defined?(@__vc_ancestor_calls) ? @__vc_ancestor_calls.dup : [] vc_ancestor_calls.unshift(instance_method(:render_template_for)) child.instance_variable_set(:@__vc_ancestor_calls, vc_ancestor_calls) diff --git a/lib/view_component/inline_template.rb b/lib/view_component/inline_template.rb index ae7ff3611..5095746ba 100644 --- a/lib/view_component/inline_template.rb +++ b/lib/view_component/inline_template.rb @@ -9,7 +9,7 @@ module InlineTemplate def method_missing(method, *args) return super if !method.end_with?("_template") - if @__vc_inline_template_defined + if defined?(@__vc_inline_template_defined) && @__vc_inline_template_defined raise MultipleInlineTemplatesError end @@ -38,11 +38,11 @@ def respond_to_missing?(method, include_all = false) end def inline_template - @__vc_inline_template + @__vc_inline_template if defined?(@__vc_inline_template) end def __vc_inline_template_language - @__vc_inline_template_language + @__vc_inline_template_language if defined?(@__vc_inline_template_language) end def inherited(subclass) diff --git a/lib/view_component/slot.rb b/lib/view_component/slot.rb index b8ef38f64..d5c745ec9 100644 --- a/lib/view_component/slot.rb +++ b/lib/view_component/slot.rb @@ -9,18 +9,13 @@ class Slot attr_writer :__vc_component_instance, :__vc_content_block, :__vc_content def initialize(parent) - @content = nil - @__vc_component_instance = nil - @__vc_content = nil - @__vc_content_block = nil - @__vc_content_set_by_with_content = nil @parent = parent end def content? - return true if @__vc_content.present? - return true if @__vc_content_set_by_with_content.present? - return true if @__vc_content_block.present? + return true if defined?(@__vc_content) && @__vc_content.present? + return true if defined?(@__vc_content_set_by_with_content) && @__vc_content_set_by_with_content.present? + return true if defined?(@__vc_content_block) && @__vc_content_block.present? return false if !__vc_component_instance? @__vc_component_instance.content? @@ -48,11 +43,11 @@ def with_content(args) # If there is no slot renderable, we evaluate the block passed to # the slot and return it. def to_s - return @content if !@content.nil? + return @content if defined?(@content) view_context = @parent.send(:view_context) - if !@__vc_content_block.nil? && !@__vc_content_set_by_with_content.nil? && !@__vc_content_set_by_with_content.nil? + if defined?(@__vc_content_block) && defined?(@__vc_content_set_by_with_content) raise DuplicateSlotContentError.new(self.class.name) end @@ -60,7 +55,7 @@ def to_s if __vc_component_instance? @__vc_component_instance.__vc_original_view_context = @parent.__vc_original_view_context - if !@__vc_content_block.nil? + if defined?(@__vc_content_block) # render_in is faster than `parent.render` @__vc_component_instance.render_in(view_context) do |*args| @__vc_content_block.call(*args) @@ -68,11 +63,11 @@ def to_s else @__vc_component_instance.render_in(view_context) end - elsif !@__vc_content.nil? + elsif defined?(@__vc_content) @__vc_content - elsif !@__vc_content_block.nil? + elsif defined?(@__vc_content_block) view_context.capture(&@__vc_content_block) - elsif !@__vc_content_set_by_with_content.nil? + elsif defined?(@__vc_content_set_by_with_content) @__vc_content_set_by_with_content end @@ -113,7 +108,7 @@ def respond_to_missing?(symbol, include_all = false) private def __vc_component_instance? - !@__vc_component_instance.nil? + defined?(@__vc_component_instance) end end end diff --git a/lib/view_component/slotable.rb b/lib/view_component/slotable.rb index 3cf68c685..7c1d0a9e7 100644 --- a/lib/view_component/slotable.rb +++ b/lib/view_component/slotable.rb @@ -421,6 +421,8 @@ def set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block) end end + @__vc_set_slots ||= {} + if slot_definition[:collection] @__vc_set_slots[slot_name] ||= [] @__vc_set_slots[slot_name].push(slot) @@ -434,7 +436,7 @@ def set_slot(slot_name, slot_definition = nil, *args, **kwargs, &block) def set_polymorphic_slot(slot_name, poly_type = nil, *args, **kwargs, &block) slot_definition = self.class.registered_slots[slot_name] - if !slot_definition[:collection] && @__vc_set_slots[slot_name] + if !slot_definition[:collection] && defined?(@__vc_set_slots) && @__vc_set_slots[slot_name] raise ContentAlreadySetForPolymorphicSlotError.new(slot_name) end diff --git a/lib/view_component/slotable_default.rb b/lib/view_component/slotable_default.rb deleted file mode 100644 index 9cc62c482..000000000 --- a/lib/view_component/slotable_default.rb +++ /dev/null @@ -1,18 +0,0 @@ -module ViewComponent - module SlotableDefault - def get_slot(slot_name) - return super unless !@__vc_set_slots[slot_name] && (default_method = registered_slots[slot_name][:default_method]) - - renderable_value = send(default_method) - slot = Slot.new(self) - - if renderable_value.respond_to?(:render_in) - slot.__vc_component_instance = renderable_value - else - slot.__vc_content = renderable_value - end - - slot - end - end -end diff --git a/performance/components/complex_component.html.erb b/performance/components/complex_component.html.erb deleted file mode 100644 index 9ff6dfed7..000000000 --- a/performance/components/complex_component.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -

hello <%= @name %>

- -<% 50.times do %> - <%= render Performance::NestedComplexComponent.new(name: @name) %> -<% end %> diff --git a/performance/components/complex_component.rb b/performance/components/complex_component.rb deleted file mode 100644 index 8113b64b7..000000000 --- a/performance/components/complex_component.rb +++ /dev/null @@ -1,8 +0,0 @@ -class Performance::ComplexComponent < ViewComponent::Base - def initialize(name:) - ("a"..."z").to_a.shuffle.each do |c| - instance_variable_set("@rand_var_#{c}", true) - end - @name = name - end -end diff --git a/performance/components/name_component.rb b/performance/components/name_component.rb index 6ee6ffff4..05e6c0cf1 100644 --- a/performance/components/name_component.rb +++ b/performance/components/name_component.rb @@ -2,9 +2,6 @@ class Performance::NameComponent < ViewComponent::Base def initialize(name:) - ("a"..."z").to_a.each do |c| - instance_variable_set("@rand_var_#{c}", true) - end @name = name end end diff --git a/performance/components/nested_complex_component.html.erb b/performance/components/nested_complex_component.html.erb deleted file mode 100644 index 94d827179..000000000 --- a/performance/components/nested_complex_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -

nested hello <%= @name %>

diff --git a/performance/components/nested_complex_component.rb b/performance/components/nested_complex_component.rb deleted file mode 100644 index 16f607352..000000000 --- a/performance/components/nested_complex_component.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class Performance::NestedComplexComponent < ViewComponent::Base - def initialize(name:) - ("a"..."z").to_a.shuffle.each do |c| - instance_variable_set("@rand_var_#{c}", true) - end - @name = name - end -end diff --git a/performance/components/nested_name_component.rb b/performance/components/nested_name_component.rb index d149c1389..7bb97f234 100644 --- a/performance/components/nested_name_component.rb +++ b/performance/components/nested_name_component.rb @@ -2,9 +2,6 @@ class Performance::NestedNameComponent < ViewComponent::Base def initialize(name:) - ("a"..."z").to_a.each do |c| - instance_variable_set("@rand_var_#{c}", true) - end @name = name end end diff --git a/performance/partial_benchmark.rb b/performance/partial_benchmark.rb index 15cb7f7b4..d7d8e179c 100644 --- a/performance/partial_benchmark.rb +++ b/performance/partial_benchmark.rb @@ -5,18 +5,16 @@ require "benchmark/ips" -Warning[:performance] = true - # Configure Rails Environment ENV["RAILS_ENV"] = "production" require File.expand_path("../test/sandbox/config/environment.rb", __dir__) +Rails.logger.level = 1 + module Performance require_relative "components/name_component" require_relative "components/nested_name_component" require_relative "components/inline_component" - require_relative "components/complex_component" - require_relative "components/nested_complex_component" end class BenchmarksController < ActionController::Base @@ -25,15 +23,12 @@ class BenchmarksController < ActionController::Base BenchmarksController.view_paths = [File.expand_path("./views", __dir__)] controller_view = BenchmarksController.new.view_context -controller_view.render(Performance::ComplexComponent.new(name: "HI there")) - Benchmark.ips do |x| x.time = 10 x.warmup = 2 x.report("component") { controller_view.render(Performance::NameComponent.new(name: "Fox Mulder")) } x.report("inline") { controller_view.render(Performance::InlineComponent.new(name: "Fox Mulder")) } - x.report("complex") { controller_view.render(Performance::ComplexComponent.new(name: "Fox Mulder")) } x.report("partial") { controller_view.render("partial", name: "Fox Mulder") } x.compare! diff --git a/test/sandbox/app/components/object_shapes_component.rb b/test/sandbox/app/components/object_shapes_component.rb deleted file mode 100644 index c20af085e..000000000 --- a/test/sandbox/app/components/object_shapes_component.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class ObjectShapesComponent < ViewComponent::Base - def initialize(name:) - ("a"..."z").to_a.shuffle.each do |c| - instance_variable_set("@rand_var_#{c}", true) - end - @name = name - end - - def call - @name.to_s.html_safe - end -end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index faf28d58f..a78b1f232 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -3,20 +3,6 @@ require "test_helper" class RenderingTest < ViewComponent::TestCase - def self.new(...) - instance = allocate - instance.__allocate_instance_variables - instance.send(:initialize, ...) - instance - end - - def __allocate_instance_variables - @page = nil - @rendered_content = nil - @vc_test_controller = nil - @vc_test_request = nil - end - def vc_test_controller_class IntegrationExamplesController end @@ -34,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 70, "3.4" => 75, "3.3" => 76, "3.2" => 75}) do + assert_allocations({"3.5" => 69, "3.4" => 74, "3.3" => 73, "3.2" => 72}) do render_inline(MyComponent.new) end end @@ -1283,18 +1269,6 @@ def test_render_anonymous_component_without_template end end - # Ensure that we pre-initialize all internal instance variables - # before rendering the component, maximizing the chance that - # Ruby will be able to use the more streamlined instance variable - # lookup enabled by object shapes. - def test_object_shapes - component = ObjectShapesComponent.new(name: SecureRandom.hex(10)) - - render_inline(component) - - assert_equal(component.instance_variables.last, :@name) - end - def test_current_template component = CurrentTemplateComponent.new diff --git a/test/sandbox/test/slotable_test.rb b/test/sandbox/test/slotable_test.rb index 80ebd8ae4..e77f75939 100644 --- a/test/sandbox/test/slotable_test.rb +++ b/test/sandbox/test/slotable_test.rb @@ -821,28 +821,4 @@ def test_overridden_slot_name_can_be_inherited def test_slot_name_methods_are_not_shared_accross_components assert_not_equal SlotsComponent.instance_method(:title).owner, SlotNameOverrideComponent::OtherComponent.instance_method(:title).owner end - - # Ensure that we pre-initialize all internal instance variables - # before rendering the component, maximizing the chance that - # Ruby will be able to use the more streamlined instance variable - # lookup enabled by object shapes. - def test_object_shapes - component = SlotsComponent.new(classes: "mt-4") do |component| - component.with_title.with_content("This is my title!") - component.with_subtitle.with_content("This is my subtitle!") - component.with_tab.with_content("Tab A") - component.with_tab.with_content("Tab B") - component.with_item.with_content("Item A") - component.with_item(highlighted: true).with_content("Item B") - component.with_item.with_content("Item C") - - component.with_footer(classes: "text-blue") do - "This is the footer" - end - end - - render_inline(component) - - assert_equal(:@classes, component.instance_variables.last) - end end diff --git a/test/test_helper.rb b/test/test_helper.rb index b297dc911..9e38f3101 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,8 +5,6 @@ require "simplecov-console" require "rails/version" -Warning[:performance] = true if RUBY_VERSION >= "3.4" - if ENV["MEASURE_COVERAGE"] SimpleCov.start do command_name "minitest-rails#{Rails::VERSION::STRING}-ruby#{RUBY_VERSION}" From 2554a01ba0b12f729c92cd3ea382584d2e9eba2b Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 3 Jun 2025 15:14:10 -0600 Subject: [PATCH 070/158] release v4.0.0.alpha6 --- Gemfile.lock | 2 +- docs/CHANGELOG.md | 2 ++ lib/view_component/version.rb | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 52cb9bce4..b1842db1a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - view_component (4.0.0.alpha5) + view_component (4.0.0.alpha6) activesupport (>= 7.1.0, < 8.1) concurrent-ruby (~> 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0d3109019..4b1c70fd6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,8 @@ nav_order: 6 ## main +## 4.0.0.alpha6 + * BREAKING: Remove `config.test_controller` in favor of `vc_test_controller_class` test helper method. *Joel Hawksley* diff --git a/lib/view_component/version.rb b/lib/view_component/version.rb index 7f10e7f5b..d59916889 100644 --- a/lib/view_component/version.rb +++ b/lib/view_component/version.rb @@ -5,7 +5,7 @@ module VERSION MAJOR = 4 MINOR = 0 PATCH = 0 - PRE = "alpha5" + PRE = "alpha6" STRING = [MAJOR, MINOR, PATCH, PRE].compact.join(".") end From d2339d4f326c04fe38d15e69bd4a76f3250223e2 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Thu, 5 Jun 2025 12:51:33 -0600 Subject: [PATCH 071/158] Remove deprecated #use_helper(s) in favor of include or helpers. (#2330) In reviewing the framework as a whole for the v4 release, we decided to remove support for the #use_helper(s) macro, as it does not align with broader Ruby patterns. --- docs/CHANGELOG.md | 4 + docs/guide/helpers.md | 66 ---------------- lib/view_component/base.rb | 2 - lib/view_component/use_helpers.rb | 41 ---------- .../use_helper_macro_component.html.erb | 23 ------ .../components/use_helper_macro_component.rb | 14 ---- .../components/use_helpers_component.html.erb | 3 - .../app/components/use_helpers_component.rb | 5 -- .../use_helpers_macro_component.html.erb | 23 ------ .../components/use_helpers_macro_component.rb | 13 ---- test/sandbox/test/rendering_test.rb | 77 ------------------- 11 files changed, 4 insertions(+), 267 deletions(-) delete mode 100644 lib/view_component/use_helpers.rb delete mode 100644 test/sandbox/app/components/use_helper_macro_component.html.erb delete mode 100644 test/sandbox/app/components/use_helper_macro_component.rb delete mode 100644 test/sandbox/app/components/use_helpers_component.html.erb delete mode 100644 test/sandbox/app/components/use_helpers_component.rb delete mode 100644 test/sandbox/app/components/use_helpers_macro_component.html.erb delete mode 100644 test/sandbox/app/components/use_helpers_macro_component.rb diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4b1c70fd6..cb85aedbb 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,10 @@ nav_order: 6 ## main +* BREAKING: Remove deprecated `use_helper(s)`. Use `include MyHelper` or `helpers.` proxy instead. + + *Joel Hawksley* + ## 4.0.0.alpha6 * BREAKING: Remove `config.test_controller` in favor of `vc_test_controller_class` test helper method. diff --git a/docs/guide/helpers.md b/docs/guide/helpers.md index 841a43555..1407fe14a 100644 --- a/docs/guide/helpers.md +++ b/docs/guide/helpers.md @@ -51,72 +51,6 @@ class UserComponent < ViewComponent::Base end ``` -## UseHelpers setter - -By default, ViewComponents don't have access to helper methods defined externally. The `use_helpers` method allows external helpers to be called from the component. - -`use_helpers` defines the helper on the component, similar to `delegate`: - -```ruby -class UseHelpersComponent < ViewComponent::Base - use_helpers :icon, :icon? - - erb_template <<-ERB -
- <%= icon? ? icon(:user) : icon(:guest) %> -
- ERB -end -``` - -Use the `from:` keyword to include individual methods defined in helper modules not available in the component: - -```ruby -class UserComponent < ViewComponent::Base - use_helpers :icon, :icon?, from: IconHelper - - def profile_icon - icon? ? icon(:user) : icon(:guest) - end -end -``` - -Use the `prefix:` keyword to prefix the helper method with the name of the helper module: - -```ruby -class UserComponent < ViewComponent::Base - use_helpers :icon, :icon?, from: IconHelper, prefix: true - - def profile_icon - icon_helper_icon? ? icon_helper_icon(:user) : icon_helper_icon(:guest) - end -end -``` - -or use the `prefix:` keyword with a custom prefix: - -```ruby -class UserComponent < ViewComponent::Base - use_helpers :icon, :icon?, from: IconHelper, prefix: :user - - def profile_icon - user_icon? ? user_icon(:user) : user_icon(:guest) - end -end -``` - -The singular version `use_helper` is also available: - -```ruby -class UserComponent < ViewComponent::Base - use_helper :icon, from: IconHelper - - def profile_icon - icon :user - end -end -``` - ## Nested URL helpers Rails nested URL helpers implicitly depend on the current `request` in certain cases. Since ViewComponent is built to enable reusing components in different contexts, nested URL helpers should be passed their options explicitly: diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 954093648..62308f264 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -14,7 +14,6 @@ require "view_component/template" require "view_component/translatable" require "view_component/with_content_helper" -require "view_component/use_helpers" module ActionView class OutputBuffer @@ -52,7 +51,6 @@ def config include ActiveSupport::CoreExt::ERBUtil include ViewComponent::InlineTemplate - include ViewComponent::UseHelpers include ViewComponent::Slotable include ViewComponent::Translatable include ViewComponent::WithContentHelper diff --git a/lib/view_component/use_helpers.rb b/lib/view_component/use_helpers.rb deleted file mode 100644 index 7e0fb482c..000000000 --- a/lib/view_component/use_helpers.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module ViewComponent::UseHelpers - extend ActiveSupport::Concern - - class_methods do - def use_helpers(*args, from: nil, prefix: false) - args.each { |helper_method| use_helper(helper_method, from: from, prefix: prefix) } - end - - def use_helper(helper_method, from: nil, prefix: false) - helper_method_name = full_helper_method_name(helper_method, prefix: prefix, source: from) - - class_eval(<<-RUBY, __FILE__, __LINE__ + 1) - def #{helper_method_name}(...) - raise HelpersCalledBeforeRenderError if view_context.nil? - - #{define_helper(helper_method: helper_method, source: from)} - end - RUBY - end - - private - - def full_helper_method_name(helper_method, prefix: false, source: nil) - return helper_method unless prefix.present? - - if !!prefix == prefix - "#{source.to_s.underscore}_#{helper_method}" - else - "#{prefix}_#{helper_method}" - end - end - - def define_helper(helper_method:, source:) - return "__vc_original_view_context.#{helper_method}(...)" unless source.present? - - "#{source}.instance_method(:#{helper_method}).bind(self).call(...)" - end - end -end diff --git a/test/sandbox/app/components/use_helper_macro_component.html.erb b/test/sandbox/app/components/use_helper_macro_component.html.erb deleted file mode 100644 index 8cd3170c9..000000000 --- a/test/sandbox/app/components/use_helper_macro_component.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -
- <%= message %> -
- -
- <%= message_with_args('macro helper method') %> -
- -
- <%= message_with_kwargs(name: 'macro kwargs helper method') %> -
- -
- <%= macro_helper_message_with_prefix('macro prefix helper method') %> -
- -
- <%= named_message_with_named_prefix('macro named prefix helper method') %> -
- -
- <%= block_content %> -
diff --git a/test/sandbox/app/components/use_helper_macro_component.rb b/test/sandbox/app/components/use_helper_macro_component.rb deleted file mode 100644 index f697e8540..000000000 --- a/test/sandbox/app/components/use_helper_macro_component.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class UseHelperMacroComponent < ViewComponent::Base - use_helper :message, from: MacroHelper - use_helper :message_with_args, from: MacroHelper - use_helper :message_with_kwargs, from: MacroHelper - use_helper :message_with_prefix, from: MacroHelper, prefix: true - use_helper :message_with_block, from: MacroHelper - use_helper :message_with_named_prefix, from: MacroHelper, prefix: :named - - def block_content - message_with_block { "Hello block helper method" } - end -end diff --git a/test/sandbox/app/components/use_helpers_component.html.erb b/test/sandbox/app/components/use_helpers_component.html.erb deleted file mode 100644 index f24542007..000000000 --- a/test/sandbox/app/components/use_helpers_component.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- <%= message %> -
diff --git a/test/sandbox/app/components/use_helpers_component.rb b/test/sandbox/app/components/use_helpers_component.rb deleted file mode 100644 index 752e83f0c..000000000 --- a/test/sandbox/app/components/use_helpers_component.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class UseHelpersComponent < ViewComponent::Base - use_helper :message -end diff --git a/test/sandbox/app/components/use_helpers_macro_component.html.erb b/test/sandbox/app/components/use_helpers_macro_component.html.erb deleted file mode 100644 index 6598497fd..000000000 --- a/test/sandbox/app/components/use_helpers_macro_component.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -
- <%= message %> -
- -
- <%= message_with_args('macro helper method') %> -
- -
- <%= message_with_kwargs(name: 'macro kwargs helper method') %> -
- -
- <%= block_content %> -
- -
- <%= macro_helper_message_with_args('macro prefix helper method') %> -
- -
- <%= named_message_with_args('macro named prefix helper method') %> -
diff --git a/test/sandbox/app/components/use_helpers_macro_component.rb b/test/sandbox/app/components/use_helpers_macro_component.rb deleted file mode 100644 index fdbe12b06..000000000 --- a/test/sandbox/app/components/use_helpers_macro_component.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class UseHelpersMacroComponent < ViewComponent::Base - use_helpers :message, :message_with_args, :message_with_kwargs, :message_with_block, from: MacroHelper - - use_helpers :message_with_args, from: MacroHelper, prefix: true - - use_helpers :message_with_args, from: MacroHelper, prefix: :named - - def block_content - message_with_block { "Hello block helper method" } - end -end diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index a78b1f232..4abb62985 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -1125,11 +1125,6 @@ def test_content_security_policy_nonce assert_selector("script", text: "\n//\n", visible: :hidden) end - def test_use_helper - render_inline(UseHelpersComponent.new) - assert_selector ".helper__message", text: "Hello helper method" - end - def test_inline_component_renders_without_trailing_whitespace without_template_annotations do render_inline(InlineTrailingWhitespaceComponent.new) @@ -1138,78 +1133,6 @@ def test_inline_component_renders_without_trailing_whitespace refute @rendered_content =~ /\s+\z/, "Rendered component contains trailing whitespace" end - def test_use_helpers_macros - render_inline(UseHelpersMacroComponent.new) - - assert_selector ".helper__message", text: "Hello helper method" - end - - def test_use_helpers_macros_with_args - render_inline(UseHelpersMacroComponent.new) - - assert_selector ".helper__args-message", text: "Hello macro helper method" - end - - def test_use_helpers_macros_with_kwargs - render_inline(UseHelpersMacroComponent.new) - - assert_selector ".helper__kwargs-message", text: "Hello macro kwargs helper method" - end - - def test_use_helpers_with_block - render_inline(UseHelpersMacroComponent.new) - - assert_selector ".helper__block-message", text: "Hello block helper method" - end - - def test_use_helper_macros - render_inline(UseHelperMacroComponent.new) - - assert_selector ".helper__message", text: "Hello helper method" - end - - def test_use_helper_macros_with_args - render_inline(UseHelperMacroComponent.new) - - assert_selector ".helper__args-message", text: "Hello macro helper method" - end - - def test_use_helper_macros_with_kwargs - render_inline(UseHelperMacroComponent.new) - - assert_selector ".helper__kwargs-message", text: "Hello macro kwargs helper method" - end - - def test_use_helper_macros_with_block - render_inline(UseHelperMacroComponent.new) - - assert_selector ".helper__block-message", text: "Hello block helper method" - end - - def test_use_helper_macros_with_prefix - render_inline(UseHelperMacroComponent.new) - - assert_selector ".helper__prefix-message", text: "Hello macro prefix helper method" - end - - def test_use_helper_macros_with_named_prefix - render_inline(UseHelperMacroComponent.new) - - assert_selector ".helper__prefix-message", text: "Hello macro named prefix helper method" - end - - def test_use_helpers_macros_with_prefix - render_inline(UseHelpersMacroComponent.new) - - assert_selector ".helper__prefix-message", text: "Hello macro prefix helper method" - end - - def test_use_helpers_macros_with_named_prefix - render_inline(UseHelpersMacroComponent.new) - - assert_selector ".helper__named-prefix-message", text: "Hello macro named prefix helper method" - end - def test_with_format with_format(:json) do render_inline(MultipleFormatsComponent.new) From 387cee938a175e3e95172fb33143b8e4ae716801 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 9 Jun 2025 09:12:57 -0600 Subject: [PATCH 072/158] Merge `main` into `v4` (#2334) * Deprecate use_helper(s) in favor of include MyHelper or helpers. (#2331) * Reduce allocations in #with_collection (#2333) * Add test of number of allocations when rendering with with_collection * Ensure one-time initializations are done before testing allocations * Cache calculated collection parameter * Cache collection_counter_parameter * Cache collection_iteration_parameter * Cache initialize_parameter_names * Add expected number of allocations for all tested Ruby/Rails combinations * Remove code handling obsolete Rails version * fix allocations --------- Co-authored-by: Matijs van Zuijlen --- docs/CHANGELOG.md | 4 ++++ lib/view_component/base.rb | 25 ++++++++++++++----------- test/sandbox/test/rendering_test.rb | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cb85aedbb..45f257ede 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -190,6 +190,10 @@ This release makes the following breaking changes: *Simon Fish* +* Deprecate `use_helper(s)`. Use `include MyHelper` or `helpers.` proxy instead. + + *Joel Hawksley* + * Reduce string allocations during compilation. *Jonathan del Strother* diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 62308f264..0c6039a0e 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -620,7 +620,7 @@ def __vc_validate_collection_parameter!(validate_default: false) parameter = validate_default ? __vc_collection_parameter : provided_collection_parameter return unless parameter - return if initialize_parameter_names.include?(parameter) || splatted_keyword_argument_present? + return if __vc_initialize_parameter_names.include?(parameter) || splatted_keyword_argument_present? # If Ruby can't parse the component class, then the initialize # parameters will be empty and ViewComponent will not be able to render @@ -637,34 +637,34 @@ def __vc_validate_collection_parameter!(validate_default: false) # methods. # @private def __vc_validate_initialization_parameters! - return unless initialize_parameter_names.include?(:content) + return unless __vc_initialize_parameter_names.include?(:content) raise ReservedParameterError.new(name, :content) end # @private def __vc_collection_parameter - provided_collection_parameter || name && name.demodulize.underscore.chomp("_component").to_sym + @provided_collection_parameter ||= name && name.demodulize.underscore.chomp("_component").to_sym end # @private def __vc_collection_counter_parameter - :"#{__vc_collection_parameter}_counter" + @__vc_collection_counter_parameter ||= :"#{__vc_collection_parameter}_counter" end # @private def __vc_counter_argument_present? - initialize_parameter_names.include?(__vc_collection_counter_parameter) + __vc_initialize_parameter_names.include?(__vc_collection_counter_parameter) end # @private def __vc_collection_iteration_parameter - :"#{__vc_collection_parameter}_iteration" + @__vc_collection_iteration_parameter ||= :"#{__vc_collection_parameter}_iteration" end # @private def __vc_iteration_argument_present? - initialize_parameter_names.include?(__vc_collection_iteration_parameter) + __vc_initialize_parameter_names.include?(__vc_collection_iteration_parameter) end private @@ -674,10 +674,13 @@ def splatted_keyword_argument_present? !initialize_parameters.include?([:keyrest, :**]) # Un-named splatted keyword args don't count! end - def initialize_parameter_names - return attribute_names.map(&:to_sym) if respond_to?(:attribute_names) - - initialize_parameters.map(&:last) + def __vc_initialize_parameter_names + @__vc_initialize_parameter_names ||= + if respond_to?(:attribute_names) + attribute_names.map(&:to_sym) + else + initialize_parameters.map(&:last) + end end def initialize_parameters diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 4abb62985..7fbb359ce 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -610,6 +610,25 @@ def test_render_collection assert_selector("p", text: "Mints counter: 1") end + def test_render_collection_inline_allocations + # Stabilize compilation status ahead of testing allocations to simulate rendering + # performance with compiled component + ViewComponent::CompileCache.cache.delete(ProductComponent) + ProductComponent.__vc_ensure_compiled + + allocations = {"3.5" => 79, "3.4" => 84, "3.3" => 110, "3.2" => 108} + + products = [Product.new(name: "Radio clock"), Product.new(name: "Mints")] + notice = "On sale" + # Ensure any one-time allocations are done + render_inline(ProductComponent.with_collection(products, notice: notice)) + + assert_allocations(**allocations) do + render_inline(ProductComponent.with_collection(products, notice: notice)) + end + assert_selector("h1", text: "Product", count: 2) + end + def test_render_collection_custom_collection_parameter_name coupons = [Coupon.new(percent_off: 20), Coupon.new(percent_off: 50)] render_inline(ProductCouponComponent.with_collection(coupons)) From 74fc048f596e9ee5ebb59ce7b45a9ba6cadb9de4 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Mon, 9 Jun 2025 10:30:22 -0600 Subject: [PATCH 073/158] [v4] better handling of splat kwargs and with_collection (#2332) * Validate compatibility with Dry::Initializer in test suite. * wip * Fix final line endings * re-add conditional for url_helpers include * use class_attribute default: false * allocations, lints * add comment about need for empty init * update changelog * fix allocations * allocations * disable instrumentation in collection allocations test * allocations * allocations * add more test cases * lints --------- Co-authored-by: GitHub Actions Bot <41898282+github-actions[bot]@users.noreply.github.com> --- Gemfile | 1 + Gemfile.lock | 2 + docs/CHANGELOG.md | 4 + docs/api.md | 8 -- gemfiles/rails_7.1.gemfile | 1 + gemfiles/rails_7.2.gemfile | 1 + gemfiles/rails_8.0.gemfile | 1 + gemfiles/rails_main.gemfile | 1 + lib/view_component/base.rb | 35 +++--- lib/view_component/errors.rb | 12 -- test/sandbox/app/components/item_component.rb | 11 ++ .../product_reader_oops_component.html.erb | 1 - .../product_reader_oops_component.rb | 16 --- test/sandbox/test/rendering_test.rb | 103 ++++++++++++------ 14 files changed, 107 insertions(+), 90 deletions(-) create mode 100644 test/sandbox/app/components/item_component.rb delete mode 100644 test/sandbox/app/components/product_reader_oops_component.html.erb delete mode 100644 test/sandbox/app/components/product_reader_oops_component.rb diff --git a/Gemfile b/Gemfile index 7388add4c..47d64944e 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ group :development, :test do gem "bundler", "~> 2" gem "capybara", "~> 3" gem "cuprite" + gem "dry-initializer", require: true gem "erb_lint" gem "haml", "~> 6" gem "jbuilder", "~> 2" diff --git a/Gemfile.lock b/Gemfile.lock index b1842db1a..69ef93aa2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -119,6 +119,7 @@ GEM diff-lcs (1.6.2) docile (1.4.1) drb (2.2.3) + dry-initializer (3.2.0) erb (5.0.1) erb_lint (0.9.0) activesupport @@ -393,6 +394,7 @@ DEPENDENCIES bundler (~> 2) capybara (~> 3) cuprite + dry-initializer erb_lint haml (~> 6) jbuilder (~> 2) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 45f257ede..bd39c3d38 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,10 @@ nav_order: 6 *Joel Hawksley* +* BREAKING: Support compatibility with `Dry::Initializer`. As a result, `EmptyOrInvalidInitializerError` will no longer be raised. + + *Joel Hawksley* + ## 4.0.0.alpha6 * BREAKING: Remove `config.test_controller` in favor of `vc_test_controller_class` test helper method. diff --git a/docs/api.md b/docs/api.md index 4a3f65f1c..5bbb08eac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -466,14 +466,6 @@ It looks like a block was provided after calling `with_content` on COMPONENT, wh To fix this issue, use either `with_content` or a block. -### `EmptyOrInvalidInitializerError` - -The COMPONENT initializer is empty or invalid. It must accept the parameter `PARAMETER` to render it as a collection. - -To fix this issue, update the initializer to accept `PARAMETER`. - -See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections. - ### `HelpersCalledBeforeRenderError` `#helpers` can't be used before rendering as it depends on the view context that only exists once a ViewComponent is passed to the Rails render pipeline. diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile index 41fa8ce93..cb1fff88e 100644 --- a/gemfiles/rails_7.1.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -14,6 +14,7 @@ group :development, :test do gem "bundler", "~> 2" gem "capybara", "~> 3" gem "cuprite" + gem "dry-initializer", require: true gem "erb_lint" gem "haml", "~> 6" gem "jbuilder", "~> 2" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 1a7629cbd..83b36ba60 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -14,6 +14,7 @@ group :development, :test do gem "bundler", "~> 2" gem "capybara", "~> 3" gem "cuprite" + gem "dry-initializer", require: true gem "erb_lint" gem "haml", "~> 6" gem "jbuilder", "~> 2" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile index 78f72aab2..ad46855a6 100644 --- a/gemfiles/rails_8.0.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -14,6 +14,7 @@ group :development, :test do gem "bundler", "~> 2" gem "capybara", "~> 3" gem "cuprite" + gem "dry-initializer", require: true gem "erb_lint" gem "haml", "~> 6" gem "jbuilder", "~> 2" diff --git a/gemfiles/rails_main.gemfile b/gemfiles/rails_main.gemfile index c43ca8c13..598dd76bb 100644 --- a/gemfiles/rails_main.gemfile +++ b/gemfiles/rails_main.gemfile @@ -15,6 +15,7 @@ group :development, :test do gem "bundler", "~> 2" gem "capybara", "~> 3" gem "cuprite" + gem "dry-initializer", require: true gem "erb_lint" gem "haml", "~> 6" gem "jbuilder", "~> 2" diff --git a/lib/view_component/base.rb b/lib/view_component/base.rb index 0c6039a0e..0f08ab580 100644 --- a/lib/view_component/base.rb +++ b/lib/view_component/base.rb @@ -47,6 +47,7 @@ def config end include ActionView::Helpers + include Rails.application.routes.url_helpers if defined?(Rails) && Rails.application include ERB::Escape include ActiveSupport::CoreExt::ERBUtil @@ -68,8 +69,7 @@ def config delegate :content_security_policy_nonce, to: :helpers # Config option that strips trailing whitespace in templates before compiling them. - class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false - self.__vc_strip_trailing_whitespace = false # class_attribute:default doesn't work until Rails 5.2 + class_attribute :__vc_strip_trailing_whitespace, instance_accessor: false, instance_predicate: false, default: false attr_accessor :__vc_original_view_context attr_reader :current_template @@ -89,6 +89,11 @@ def set_original_view_context(view_context) using RequestDetails + # Including `Rails.application.routes.url_helpers` defines an initializer that accepts (...), + # so we have to define our own empty initializer to overwrite it. + def initialize + end + # Entrypoint for rendering components. # # - `view_context`: ActionView context from calling view @@ -509,6 +514,11 @@ def with_collection(collection, spacer_component: nil, **args) Collection.new(self, collection, spacer_component, **args) end + # @private + def __vc_compile(raise_errors: false, force: false) + __vc_compiler.compile(raise_errors: raise_errors, force: force) + end + # @private def inherited(child) # Compile so child will inherit compiled `call_*` template methods that @@ -531,12 +541,6 @@ def render_template_for(requested_details) RUBY end - # If Rails application is loaded, add application url_helpers to the component context - # we need to check this to use this gem as a dependency - if defined?(Rails) && Rails.application && !(child < Rails.application.routes.url_helpers) - child.include Rails.application.routes.url_helpers - end - # Derive the source location of the component Ruby file from the call stack. # We need to ignore `inherited` frames here as they indicate that `inherited` # has been re-defined by the consuming application, likely in ApplicationComponent. @@ -568,11 +572,6 @@ def __vc_ensure_compiled __vc_compile unless __vc_compiled? end - # @private - def __vc_compile(raise_errors: false, force: false) - __vc_compiler.compile(raise_errors: raise_errors, force: force) - end - # @private def __vc_compiler @__vc_compiler ||= Compiler.new(self) @@ -622,13 +621,6 @@ def __vc_validate_collection_parameter!(validate_default: false) return unless parameter return if __vc_initialize_parameter_names.include?(parameter) || splatted_keyword_argument_present? - # If Ruby can't parse the component class, then the initialize - # parameters will be empty and ViewComponent will not be able to render - # the component. - if initialize_parameters.empty? - raise EmptyOrInvalidInitializerError.new(name, parameter) - end - raise MissingCollectionArgumentError.new(name, parameter) end @@ -670,8 +662,7 @@ def __vc_iteration_argument_present? private def splatted_keyword_argument_present? - initialize_parameters.flatten.include?(:keyrest) && - !initialize_parameters.include?([:keyrest, :**]) # Un-named splatted keyword args don't count! + initialize_parameters.flatten.include?(:keyrest) end def __vc_initialize_parameter_names diff --git a/lib/view_component/errors.rb b/lib/view_component/errors.rb index f101a91f5..b6aa38e4d 100644 --- a/lib/view_component/errors.rb +++ b/lib/view_component/errors.rb @@ -65,18 +65,6 @@ def initialize(klass_name) end end - class EmptyOrInvalidInitializerError < StandardError - MESSAGE = - "The COMPONENT initializer is empty or invalid. " \ - "It must accept the parameter `PARAMETER` to render it as a collection.\n\n" \ - "To fix this issue, update the initializer to accept `PARAMETER`.\n\n" \ - "See [the collections docs](https://viewcomponent.org/guide/collections.html) for more information on rendering collections." - - def initialize(klass_name, parameter) - super(MESSAGE.gsub("COMPONENT", klass_name.to_s).gsub("PARAMETER", parameter.to_s)) - end - end - class MissingCollectionArgumentError < StandardError MESSAGE = "The initializer for COMPONENT doesn't accept the parameter `PARAMETER`, " \ diff --git a/test/sandbox/app/components/item_component.rb b/test/sandbox/app/components/item_component.rb new file mode 100644 index 000000000..6794ea7c9 --- /dev/null +++ b/test/sandbox/app/components/item_component.rb @@ -0,0 +1,11 @@ +require "dry-initializer" + +class ItemComponent < ViewComponent::Base + extend Dry::Initializer + + option :item + + erb_template <<~ERB + <%= item.name %> + ERB +end diff --git a/test/sandbox/app/components/product_reader_oops_component.html.erb b/test/sandbox/app/components/product_reader_oops_component.html.erb deleted file mode 100644 index 1038ae1b1..000000000 --- a/test/sandbox/app/components/product_reader_oops_component.html.erb +++ /dev/null @@ -1 +0,0 @@ -
Hello, World!
diff --git a/test/sandbox/app/components/product_reader_oops_component.rb b/test/sandbox/app/components/product_reader_oops_component.rb deleted file mode 100644 index ad3361102..000000000 --- a/test/sandbox/app/components/product_reader_oops_component.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This code intentionally has a bug where there is an extra comma after the -# :notice reader. `Kernel.silence_warnings` is in place to avoid Ruby emitting -# warnings in test output. - -# rubocop:disable all -Kernel.silence_warnings do - class ProductReaderOopsComponent < ViewComponent::Base - attr_reader :product, :notice, - - def initialize(product_reader_oops:) - end - end -end -# rubocop:enable all diff --git a/test/sandbox/test/rendering_test.rb b/test/sandbox/test/rendering_test.rb index 7fbb359ce..14b19836d 100644 --- a/test/sandbox/test/rendering_test.rb +++ b/test/sandbox/test/rendering_test.rb @@ -20,7 +20,7 @@ def test_render_inline_allocations MyComponent.__vc_ensure_compiled with_instrumentation_enabled_option(false) do - assert_allocations({"3.5" => 69, "3.4" => 74, "3.3" => 73, "3.2" => 72}) do + assert_allocations({"3.5" => 69, "3.4" => 74, "3.3" => 72, "3.2" => 71}) do render_inline(MyComponent.new) end end @@ -28,6 +28,28 @@ def test_render_inline_allocations assert_selector("div", text: "hello,world!") end + def test_render_collection_inline_allocations + # Stabilize compilation status ahead of testing allocations to simulate rendering + # performance with compiled component + ViewComponent::CompileCache.cache.delete(ProductComponent) + ProductComponent.__vc_ensure_compiled + + allocations = {"3.5" => 77, "3.4" => 82, "3.3" => 86, "3.2" => 84} + + products = [Product.new(name: "Radio clock"), Product.new(name: "Mints")] + notice = "On sale" + # Ensure any one-time allocations are done + render_inline(ProductComponent.with_collection(products, notice: notice)) + + with_instrumentation_enabled_option(false) do + assert_allocations(**allocations) do + render_inline(ProductComponent.with_collection(products, notice: notice)) + end + end + + assert_selector("h1", text: "Product", count: 2) + end + def test_initialize_super render_inline(InitializeSuperComponent.new) @@ -610,25 +632,6 @@ def test_render_collection assert_selector("p", text: "Mints counter: 1") end - def test_render_collection_inline_allocations - # Stabilize compilation status ahead of testing allocations to simulate rendering - # performance with compiled component - ViewComponent::CompileCache.cache.delete(ProductComponent) - ProductComponent.__vc_ensure_compiled - - allocations = {"3.5" => 79, "3.4" => 84, "3.3" => 110, "3.2" => 108} - - products = [Product.new(name: "Radio clock"), Product.new(name: "Mints")] - notice = "On sale" - # Ensure any one-time allocations are done - render_inline(ProductComponent.with_collection(products, notice: notice)) - - assert_allocations(**allocations) do - render_inline(ProductComponent.with_collection(products, notice: notice)) - end - assert_selector("h1", text: "Product", count: 2) - end - def test_render_collection_custom_collection_parameter_name coupons = [Coupon.new(percent_off: 20), Coupon.new(percent_off: 50)] render_inline(ProductCouponComponent.with_collection(coupons)) @@ -785,17 +788,6 @@ def test_component_with_invalid_named_parameter_names end end - def test_collection_component_with_trailing_comma_attr_reader - exception = - assert_raises ViewComponent::EmptyOrInvalidInitializerError do - render_inline( - ProductReaderOopsComponent.with_collection(["foo"]) - ) - end - - assert_match(/ProductReaderOopsComponent initializer is empty or invalid/, exception.message) - end - def test_renders_component_using_rails_config render_inline(RailsConfigComponent.new) @@ -1241,4 +1233,53 @@ def test_custom_base assert_includes("Hi!", custom_view.render(GreetingComponent.new)) end + + def test_dry_initializer + render_inline(ItemComponent.with_collection([Product.new(name: "Radio clock")])) + + assert_text("Radio clock") + end + + class DynamicComponentBase < ViewComponent::Base + def setup_component(**attributes) + # This method is somewhat contrived, it's intended to mimic features available in the dry-initializer gem. + model_name = self.class.name.demodulize.delete_suffix("Component").underscore.to_sym + instance_variable_set(:"@#{model_name}", attributes[model_name]) + define_singleton_method(model_name) { instance_variable_get(:"@#{model_name}") } + end + end + + class OrderComponent < DynamicComponentBase + def initialize(**) + setup_component(**) + end + + def call + "

#{order.name}

".html_safe + end + end + + class CustomerComponent < DynamicComponentBase + def initialize(...) + setup_component(...) + end + + def call + "

#{customer.name}

".html_safe + end + end + + def test_supports_components_with_argument_forwarding + customers = [Product.new(name: "Taylor"), Product.new(name: "Rowan")] + render_inline(CustomerComponent.with_collection(customers)) + assert_selector("*[data-name='#{customers.first.name}']", text: customers.first.name) + assert_selector("*[data-name='#{customers.last.name}']", text: customers.last.name) + end + + def test_supports_components_with_unnamed_splatted_arguments + orders = [Product.new(name: "O-2024-0004"), Product.new(name: "B-2024-0714")] + render_inline(OrderComponent.with_collection(orders)) + assert_selector("*[data-name='#{orders.first.name}']", text: orders.first.name) + assert_selector("*[data-name='#{orders.last.name}']", text: orders.last.name) + end end From 6dd8b0ee693deab28ef7122fdeb43e87144e4fff Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Tue, 10 Jun 2025 16:02:46 -0600 Subject: [PATCH 074/158] add failing test for #2212 --- .../turbo_content_type_component.rb | 31 + .../integration_examples_controller.rb | 7 + .../turbo_content_type.html.erb | 20 + test/sandbox/config/routes.rb | 2 + test/sandbox/public/turbo.es2017-esm.js | 6568 +++++++++++++++++ .../test/view_component_system_test.rb | 8 + 6 files changed, 6636 insertions(+) create mode 100644 test/sandbox/app/components/turbo_content_type_component.rb create mode 100644 test/sandbox/app/views/integration_examples/turbo_content_type.html.erb create mode 100644 test/sandbox/public/turbo.es2017-esm.js diff --git a/test/sandbox/app/components/turbo_content_type_component.rb b/test/sandbox/app/components/turbo_content_type_component.rb new file mode 100644 index 000000000..e052fb788 --- /dev/null +++ b/test/sandbox/app/components/turbo_content_type_component.rb @@ -0,0 +1,31 @@ +class TurboContentTypeComponent < ViewComponent::Base + def initialize(message: "Enter a message:", show_form: true) + @message = message + @show_form = show_form + end + + def call + content_tag('turbo-frame', id: "test-frame") do + if show_form + form_content + else + content_tag(:div, @message, id: "result") + end + end + end + + private + + attr_reader :message, :show_form + + def form_content + form_tag("/submit", method: :post, data: { turbo_frame: "test-frame" }) do + content_tag(:div) do + content_tag(:h2, @message) + + text_field_tag("user_message", "", value: "Test message") + + content_tag(:br) + + submit_tag("Submit", id: "submit") + end + end + end +end \ No newline at end of file diff --git a/test/sandbox/app/controllers/integration_examples_controller.rb b/test/sandbox/app/controllers/integration_examples_controller.rb index b5e8dd505..46c6d31df 100644 --- a/test/sandbox/app/controllers/integration_examples_controller.rb +++ b/test/sandbox/app/controllers/integration_examples_controller.rb @@ -69,4 +69,11 @@ def multiple_formats_component def turbo_stream respond_to { |format| format.turbo_stream { render TurboStreamComponent.new } } end + + def submit + render TurboContentTypeComponent.new( + message: "Submitted", + show_form: false + )#, content_type: "text/html" + end end diff --git a/test/sandbox/app/views/integration_examples/turbo_content_type.html.erb b/test/sandbox/app/views/integration_examples/turbo_content_type.html.erb new file mode 100644 index 000000000..68d6ccddf --- /dev/null +++ b/test/sandbox/app/views/integration_examples/turbo_content_type.html.erb @@ -0,0 +1,20 @@ + + + + Turbo + ViewComponent Content-Type Issue Demo + + + + +

Turbo + ViewComponent Content-Type Issue Demo

+

Check Content-Type header when form is submitted. Open developer tools (Network tab) before submitting.

+ + <%= render TurboContentTypeComponent.new %> + + \ No newline at end of file diff --git a/test/sandbox/config/routes.rb b/test/sandbox/config/routes.rb index 0ffb8847d..7c73ceeef 100644 --- a/test/sandbox/config/routes.rb +++ b/test/sandbox/config/routes.rb @@ -32,6 +32,8 @@ get :multiple_formats_component, to: "integration_examples#multiple_formats_component" get :slotable_default_override, to: "integration_examples#slotable_default_override" post :create, to: "integration_examples#create" + get :turbo_content_type, to: "integration_examples#turbo_content_type" + post :submit, to: "integration_examples#submit" constraints(lambda { |request| request.env["warden"].authenticate! }) do get :constraints_with_env, to: "integration_examples#index" diff --git a/test/sandbox/public/turbo.es2017-esm.js b/test/sandbox/public/turbo.es2017-esm.js new file mode 100644 index 000000000..e6374a8ab --- /dev/null +++ b/test/sandbox/public/turbo.es2017-esm.js @@ -0,0 +1,6568 @@ +/*! +Turbo 8.0.3 +Copyright © 2024 37signals LLC + */ +/** + * The MIT License (MIT) + * + * Copyright (c) 2019 Javan Makhmali + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +(function (prototype) { + if (typeof prototype.requestSubmit == "function") return + + prototype.requestSubmit = function (submitter) { + if (submitter) { + validateSubmitter(submitter, this); + submitter.click(); + } else { + submitter = document.createElement("input"); + submitter.type = "submit"; + submitter.hidden = true; + this.appendChild(submitter); + submitter.click(); + this.removeChild(submitter); + } + }; + + function validateSubmitter(submitter, form) { + submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); + submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button"); + submitter.form == form || + raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); + } + + function raise(errorConstructor, message, name) { + throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name) + } +})(HTMLFormElement.prototype); + +const submittersByForm = new WeakMap(); + +function findSubmitterFromClickTarget(target) { + const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; + const candidate = element ? element.closest("input, button") : null; + return candidate?.type == "submit" ? candidate : null +} + +function clickCaptured(event) { + const submitter = findSubmitterFromClickTarget(event.target); + + if (submitter && submitter.form) { + submittersByForm.set(submitter.form, submitter); + } +} + +(function () { + if ("submitter" in Event.prototype) return + + let prototype = window.Event.prototype; + // Certain versions of Safari 15 have a bug where they won't + // populate the submitter. This hurts TurboDrive's enable/disable detection. + // See https://bugs.webkit.org/show_bug.cgi?id=229660 + if ("SubmitEvent" in window) { + const prototypeOfSubmitEvent = window.SubmitEvent.prototype; + + if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { + prototype = prototypeOfSubmitEvent; + } else { + return // polyfill not needed + } + } + + addEventListener("click", clickCaptured, true); + + Object.defineProperty(prototype, "submitter", { + get() { + if (this.type == "submit" && this.target instanceof HTMLFormElement) { + return submittersByForm.get(this.target) + } + } + }); +})(); + +const FrameLoadingStyle = { + eager: "eager", + lazy: "lazy" +}; + +/** + * Contains a fragment of HTML which is updated based on navigation within + * it (e.g. via links or form submissions). + * + * @customElement turbo-frame + * @example + * + * + * Show all expanded messages in this frame. + * + * + *
+ * Show response from this form within this frame. + *
+ *
+ */ +class FrameElement extends HTMLElement { + static delegateConstructor = undefined + + loaded = Promise.resolve() + + static get observedAttributes() { + return ["disabled", "complete", "loading", "src"] + } + + constructor() { + super(); + this.delegate = new FrameElement.delegateConstructor(this); + } + + connectedCallback() { + this.delegate.connect(); + } + + disconnectedCallback() { + this.delegate.disconnect(); + } + + reload() { + return this.delegate.sourceURLReloaded() + } + + attributeChangedCallback(name) { + if (name == "loading") { + this.delegate.loadingStyleChanged(); + } else if (name == "complete") { + this.delegate.completeChanged(); + } else if (name == "src") { + this.delegate.sourceURLChanged(); + } else { + this.delegate.disabledChanged(); + } + } + + /** + * Gets the URL to lazily load source HTML from + */ + get src() { + return this.getAttribute("src") + } + + /** + * Sets the URL to lazily load source HTML from + */ + set src(value) { + if (value) { + this.setAttribute("src", value); + } else { + this.removeAttribute("src"); + } + } + + /** + * Gets the refresh mode for the frame. + */ + get refresh() { + return this.getAttribute("refresh") + } + + /** + * Sets the refresh mode for the frame. + */ + set refresh(value) { + if (value) { + this.setAttribute("refresh", value); + } else { + this.removeAttribute("refresh"); + } + } + + /** + * Determines if the element is loading + */ + get loading() { + return frameLoadingStyleFromString(this.getAttribute("loading") || "") + } + + /** + * Sets the value of if the element is loading + */ + set loading(value) { + if (value) { + this.setAttribute("loading", value); + } else { + this.removeAttribute("loading"); + } + } + + /** + * Gets the disabled state of the frame. + * + * If disabled, no requests will be intercepted by the frame. + */ + get disabled() { + return this.hasAttribute("disabled") + } + + /** + * Sets the disabled state of the frame. + * + * If disabled, no requests will be intercepted by the frame. + */ + set disabled(value) { + if (value) { + this.setAttribute("disabled", ""); + } else { + this.removeAttribute("disabled"); + } + } + + /** + * Gets the autoscroll state of the frame. + * + * If true, the frame will be scrolled into view automatically on update. + */ + get autoscroll() { + return this.hasAttribute("autoscroll") + } + + /** + * Sets the autoscroll state of the frame. + * + * If true, the frame will be scrolled into view automatically on update. + */ + set autoscroll(value) { + if (value) { + this.setAttribute("autoscroll", ""); + } else { + this.removeAttribute("autoscroll"); + } + } + + /** + * Determines if the element has finished loading + */ + get complete() { + return !this.delegate.isLoading + } + + /** + * Gets the active state of the frame. + * + * If inactive, source changes will not be observed. + */ + get isActive() { + return this.ownerDocument === document && !this.isPreview + } + + /** + * Sets the active state of the frame. + * + * If inactive, source changes will not be observed. + */ + get isPreview() { + return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview") + } +} + +function frameLoadingStyleFromString(style) { + switch (style.toLowerCase()) { + case "lazy": + return FrameLoadingStyle.lazy + default: + return FrameLoadingStyle.eager + } +} + +function expandURL(locatable) { + return new URL(locatable.toString(), document.baseURI) +} + +function getAnchor(url) { + let anchorMatch; + if (url.hash) { + return url.hash.slice(1) + // eslint-disable-next-line no-cond-assign + } else if ((anchorMatch = url.href.match(/#(.*)$/))) { + return anchorMatch[1] + } +} + +function getAction$1(form, submitter) { + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action; + + return expandURL(action) +} + +function getExtension(url) { + return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || "" +} + +function isHTML(url) { + return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/) +} + +function isPrefixedBy(baseURL, url) { + const prefix = getPrefix(url); + return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix) +} + +function locationIsVisitable(location, rootLocation) { + return isPrefixedBy(location, rootLocation) && isHTML(location) +} + +function getRequestURL(url) { + const anchor = getAnchor(url); + return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href +} + +function toCacheKey(url) { + return getRequestURL(url) +} + +function urlsAreEqual(left, right) { + return expandURL(left).href == expandURL(right).href +} + +function getPathComponents(url) { + return url.pathname.split("/").slice(1) +} + +function getLastPathComponent(url) { + return getPathComponents(url).slice(-1)[0] +} + +function getPrefix(url) { + return addTrailingSlash(url.origin + url.pathname) +} + +function addTrailingSlash(value) { + return value.endsWith("/") ? value : value + "/" +} + +class FetchResponse { + constructor(response) { + this.response = response; + } + + get succeeded() { + return this.response.ok + } + + get failed() { + return !this.succeeded + } + + get clientError() { + return this.statusCode >= 400 && this.statusCode <= 499 + } + + get serverError() { + return this.statusCode >= 500 && this.statusCode <= 599 + } + + get redirected() { + return this.response.redirected + } + + get location() { + return expandURL(this.response.url) + } + + get isHTML() { + return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/) + } + + get statusCode() { + return this.response.status + } + + get contentType() { + return this.header("Content-Type") + } + + get responseText() { + return this.response.clone().text() + } + + get responseHTML() { + if (this.isHTML) { + return this.response.clone().text() + } else { + return Promise.resolve(undefined) + } + } + + header(name) { + return this.response.headers.get(name) + } +} + +function activateScriptElement(element) { + if (element.getAttribute("data-turbo-eval") == "false") { + return element + } else { + const createdScriptElement = document.createElement("script"); + const cspNonce = getMetaContent("csp-nonce"); + if (cspNonce) { + createdScriptElement.nonce = cspNonce; + } + createdScriptElement.textContent = element.textContent; + createdScriptElement.async = false; + copyElementAttributes(createdScriptElement, element); + return createdScriptElement + } +} + +function copyElementAttributes(destinationElement, sourceElement) { + for (const { name, value } of sourceElement.attributes) { + destinationElement.setAttribute(name, value); + } +} + +function createDocumentFragment(html) { + const template = document.createElement("template"); + template.innerHTML = html; + return template.content +} + +function dispatch(eventName, { target, cancelable, detail } = {}) { + const event = new CustomEvent(eventName, { + cancelable, + bubbles: true, + composed: true, + detail + }); + + if (target && target.isConnected) { + target.dispatchEvent(event); + } else { + document.documentElement.dispatchEvent(event); + } + + return event +} + +function nextRepaint() { + if (document.visibilityState === "hidden") { + return nextEventLoopTick() + } else { + return nextAnimationFrame() + } +} + +function nextAnimationFrame() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())) +} + +function nextEventLoopTick() { + return new Promise((resolve) => setTimeout(() => resolve(), 0)) +} + +function nextMicrotask() { + return Promise.resolve() +} + +function parseHTMLDocument(html = "") { + return new DOMParser().parseFromString(html, "text/html") +} + +function unindent(strings, ...values) { + const lines = interpolate(strings, values).replace(/^\n/, "").split("\n"); + const match = lines[0].match(/^\s+/); + const indent = match ? match[0].length : 0; + return lines.map((line) => line.slice(indent)).join("\n") +} + +function interpolate(strings, values) { + return strings.reduce((result, string, i) => { + const value = values[i] == undefined ? "" : values[i]; + return result + string + value + }, "") +} + +function uuid() { + return Array.from({ length: 36 }) + .map((_, i) => { + if (i == 8 || i == 13 || i == 18 || i == 23) { + return "-" + } else if (i == 14) { + return "4" + } else if (i == 19) { + return (Math.floor(Math.random() * 4) + 8).toString(16) + } else { + return Math.floor(Math.random() * 15).toString(16) + } + }) + .join("") +} + +function getAttribute(attributeName, ...elements) { + for (const value of elements.map((element) => element?.getAttribute(attributeName))) { + if (typeof value == "string") return value + } + + return null +} + +function hasAttribute(attributeName, ...elements) { + return elements.some((element) => element && element.hasAttribute(attributeName)) +} + +function markAsBusy(...elements) { + for (const element of elements) { + if (element.localName == "turbo-frame") { + element.setAttribute("busy", ""); + } + element.setAttribute("aria-busy", "true"); + } +} + +function clearBusyState(...elements) { + for (const element of elements) { + if (element.localName == "turbo-frame") { + element.removeAttribute("busy"); + } + + element.removeAttribute("aria-busy"); + } +} + +function waitForLoad(element, timeoutInMilliseconds = 2000) { + return new Promise((resolve) => { + const onComplete = () => { + element.removeEventListener("error", onComplete); + element.removeEventListener("load", onComplete); + resolve(); + }; + + element.addEventListener("load", onComplete, { once: true }); + element.addEventListener("error", onComplete, { once: true }); + setTimeout(resolve, timeoutInMilliseconds); + }) +} + +function getHistoryMethodForAction(action) { + switch (action) { + case "replace": + return history.replaceState + case "advance": + case "restore": + return history.pushState + } +} + +function isAction(action) { + return action == "advance" || action == "replace" || action == "restore" +} + +function getVisitAction(...elements) { + const action = getAttribute("data-turbo-action", ...elements); + + return isAction(action) ? action : null +} + +function getMetaElement(name) { + return document.querySelector(`meta[name="${name}"]`) +} + +function getMetaContent(name) { + const element = getMetaElement(name); + return element && element.content +} + +function setMetaContent(name, content) { + let element = getMetaElement(name); + + if (!element) { + element = document.createElement("meta"); + element.setAttribute("name", name); + + document.head.appendChild(element); + } + + element.setAttribute("content", content); + + return element +} + +function findClosestRecursively(element, selector) { + if (element instanceof Element) { + return ( + element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector) + ) + } +} + +function elementIsFocusable(element) { + const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; + + return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function" +} + +function queryAutofocusableElement(elementOrDocumentFragment) { + return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable) +} + +async function around(callback, reader) { + const before = reader(); + + callback(); + + await nextAnimationFrame(); + + const after = reader(); + + return [before, after] +} + +function doesNotTargetIFrame(anchor) { + if (anchor.hasAttribute("target")) { + for (const element of document.getElementsByName(anchor.target)) { + if (element instanceof HTMLIFrameElement) return false + } + } + + return true +} + +function findLinkFromClickTarget(target) { + return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])") +} + +function getLocationForLink(link) { + return expandURL(link.getAttribute("href") || "") +} + +function debounce(fn, delay) { + let timeoutId = null; + + return (...args) => { + const callback = () => fn.apply(this, args); + clearTimeout(timeoutId); + timeoutId = setTimeout(callback, delay); + } +} + +class LimitedSet extends Set { + constructor(maxSize) { + super(); + this.maxSize = maxSize; + } + + add(value) { + if (this.size >= this.maxSize) { + const iterator = this.values(); + const oldestValue = iterator.next().value; + this.delete(oldestValue); + } + super.add(value); + } +} + +const recentRequests = new LimitedSet(20); + +const nativeFetch = window.fetch; + +function fetchWithTurboHeaders(url, options = {}) { + const modifiedHeaders = new Headers(options.headers || {}); + const requestUID = uuid(); + recentRequests.add(requestUID); + modifiedHeaders.append("X-Turbo-Request-Id", requestUID); + + return nativeFetch(url, { + ...options, + headers: modifiedHeaders + }) +} + +function fetchMethodFromString(method) { + switch (method.toLowerCase()) { + case "get": + return FetchMethod.get + case "post": + return FetchMethod.post + case "put": + return FetchMethod.put + case "patch": + return FetchMethod.patch + case "delete": + return FetchMethod.delete + } +} + +const FetchMethod = { + get: "get", + post: "post", + put: "put", + patch: "patch", + delete: "delete" +}; + +function fetchEnctypeFromString(encoding) { + switch (encoding.toLowerCase()) { + case FetchEnctype.multipart: + return FetchEnctype.multipart + case FetchEnctype.plain: + return FetchEnctype.plain + default: + return FetchEnctype.urlEncoded + } +} + +const FetchEnctype = { + urlEncoded: "application/x-www-form-urlencoded", + multipart: "multipart/form-data", + plain: "text/plain" +}; + +class FetchRequest { + abortController = new AbortController() + #resolveRequestPromise = (_value) => {} + + constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) { + const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype); + + this.delegate = delegate; + this.url = url; + this.target = target; + this.fetchOptions = { + credentials: "same-origin", + redirect: "follow", + method: method, + headers: { ...this.defaultHeaders }, + body: body, + signal: this.abortSignal, + referrer: this.delegate.referrer?.href + }; + this.enctype = enctype; + } + + get method() { + return this.fetchOptions.method + } + + set method(value) { + const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData(); + const fetchMethod = fetchMethodFromString(value) || FetchMethod.get; + + this.url.search = ""; + + const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype); + + this.url = url; + this.fetchOptions.body = body; + this.fetchOptions.method = fetchMethod; + } + + get headers() { + return this.fetchOptions.headers + } + + set headers(value) { + this.fetchOptions.headers = value; + } + + get body() { + if (this.isSafe) { + return this.url.searchParams + } else { + return this.fetchOptions.body + } + } + + set body(value) { + this.fetchOptions.body = value; + } + + get location() { + return this.url + } + + get params() { + return this.url.searchParams + } + + get entries() { + return this.body ? Array.from(this.body.entries()) : [] + } + + cancel() { + this.abortController.abort(); + } + + async perform() { + const { fetchOptions } = this; + this.delegate.prepareRequest(this); + const event = await this.#allowRequestToBeIntercepted(fetchOptions); + try { + this.delegate.requestStarted(this); + + if (event.detail.fetchRequest) { + this.response = event.detail.fetchRequest.response; + } else { + this.response = fetchWithTurboHeaders(this.url.href, fetchOptions); + } + + const response = await this.response; + return await this.receive(response) + } catch (error) { + if (error.name !== "AbortError") { + if (this.#willDelegateErrorHandling(error)) { + this.delegate.requestErrored(this, error); + } + throw error + } + } finally { + this.delegate.requestFinished(this); + } + } + + async receive(response) { + const fetchResponse = new FetchResponse(response); + const event = dispatch("turbo:before-fetch-response", { + cancelable: true, + detail: { fetchResponse }, + target: this.target + }); + if (event.defaultPrevented) { + this.delegate.requestPreventedHandlingResponse(this, fetchResponse); + } else if (fetchResponse.succeeded) { + this.delegate.requestSucceededWithResponse(this, fetchResponse); + } else { + this.delegate.requestFailedWithResponse(this, fetchResponse); + } + return fetchResponse + } + + get defaultHeaders() { + return { + Accept: "text/html, application/xhtml+xml" + } + } + + get isSafe() { + return isSafe(this.method) + } + + get abortSignal() { + return this.abortController.signal + } + + acceptResponseType(mimeType) { + this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", "); + } + + async #allowRequestToBeIntercepted(fetchOptions) { + const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve)); + const event = dispatch("turbo:before-fetch-request", { + cancelable: true, + detail: { + fetchOptions, + url: this.url, + resume: this.#resolveRequestPromise + }, + target: this.target + }); + this.url = event.detail.url; + if (event.defaultPrevented) await requestInterception; + + return event + } + + #willDelegateErrorHandling(error) { + const event = dispatch("turbo:fetch-request-error", { + target: this.target, + cancelable: true, + detail: { request: this, error: error } + }); + + return !event.defaultPrevented + } +} + +function isSafe(fetchMethod) { + return fetchMethodFromString(fetchMethod) == FetchMethod.get +} + +function buildResourceAndBody(resource, method, requestBody, enctype) { + const searchParams = + Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams; + + if (isSafe(method)) { + return [mergeIntoURLSearchParams(resource, searchParams), null] + } else if (enctype == FetchEnctype.urlEncoded) { + return [resource, searchParams] + } else { + return [resource, requestBody] + } +} + +function entriesExcludingFiles(requestBody) { + const entries = []; + + for (const [name, value] of requestBody) { + if (value instanceof File) continue + else entries.push([name, value]); + } + + return entries +} + +function mergeIntoURLSearchParams(url, requestBody) { + const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); + + url.search = searchParams.toString(); + + return url +} + +class AppearanceObserver { + started = false + + constructor(delegate, element) { + this.delegate = delegate; + this.element = element; + this.intersectionObserver = new IntersectionObserver(this.intersect); + } + + start() { + if (!this.started) { + this.started = true; + this.intersectionObserver.observe(this.element); + } + } + + stop() { + if (this.started) { + this.started = false; + this.intersectionObserver.unobserve(this.element); + } + } + + intersect = (entries) => { + const lastEntry = entries.slice(-1)[0]; + if (lastEntry?.isIntersecting) { + this.delegate.elementAppearedInViewport(this.element); + } + } +} + +class StreamMessage { + static contentType = "text/vnd.turbo-stream.html" + + static wrap(message) { + if (typeof message == "string") { + return new this(createDocumentFragment(message)) + } else { + return message + } + } + + constructor(fragment) { + this.fragment = importStreamElements(fragment); + } +} + +function importStreamElements(fragment) { + for (const element of fragment.querySelectorAll("turbo-stream")) { + const streamElement = document.importNode(element, true); + + for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { + inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)); + } + + element.replaceWith(streamElement); + } + + return fragment +} + +const PREFETCH_DELAY = 100; + +class PrefetchCache { + #prefetchTimeout = null + #prefetched = null + + get(url) { + if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { + return this.#prefetched.request + } + } + + setLater(url, request, ttl) { + this.clear(); + + this.#prefetchTimeout = setTimeout(() => { + request.perform(); + this.set(url, request, ttl); + this.#prefetchTimeout = null; + }, PREFETCH_DELAY); + } + + set(url, request, ttl) { + this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }; + } + + clear() { + if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); + this.#prefetched = null; + } +} + +const cacheTtl = 10 * 1000; +const prefetchCache = new PrefetchCache(); + +const FormSubmissionState = { + initialized: "initialized", + requesting: "requesting", + waiting: "waiting", + receiving: "receiving", + stopping: "stopping", + stopped: "stopped" +}; + +class FormSubmission { + state = FormSubmissionState.initialized + + static confirmMethod(message, _element, _submitter) { + return Promise.resolve(confirm(message)) + } + + constructor(delegate, formElement, submitter, mustRedirect = false) { + const method = getMethod(formElement, submitter); + const action = getAction(getFormAction(formElement, submitter), method); + const body = buildFormData(formElement, submitter); + const enctype = getEnctype(formElement, submitter); + + this.delegate = delegate; + this.formElement = formElement; + this.submitter = submitter; + this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype); + this.mustRedirect = mustRedirect; + } + + get method() { + return this.fetchRequest.method + } + + set method(value) { + this.fetchRequest.method = value; + } + + get action() { + return this.fetchRequest.url.toString() + } + + set action(value) { + this.fetchRequest.url = expandURL(value); + } + + get body() { + return this.fetchRequest.body + } + + get enctype() { + return this.fetchRequest.enctype + } + + get isSafe() { + return this.fetchRequest.isSafe + } + + get location() { + return this.fetchRequest.url + } + + // The submission process + + async start() { + const { initialized, requesting } = FormSubmissionState; + const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); + + if (typeof confirmationMessage === "string") { + const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter); + if (!answer) { + return + } + } + + if (this.state == initialized) { + this.state = requesting; + return this.fetchRequest.perform() + } + } + + stop() { + const { stopping, stopped } = FormSubmissionState; + if (this.state != stopping && this.state != stopped) { + this.state = stopping; + this.fetchRequest.cancel(); + return true + } + } + + // Fetch request delegate + + prepareRequest(request) { + if (!request.isSafe) { + const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token"); + if (token) { + request.headers["X-CSRF-Token"] = token; + } + } + + if (this.requestAcceptsTurboStreamResponse(request)) { + request.acceptResponseType(StreamMessage.contentType); + } + } + + requestStarted(_request) { + this.state = FormSubmissionState.waiting; + this.submitter?.setAttribute("disabled", ""); + this.setSubmitsWith(); + markAsBusy(this.formElement); + dispatch("turbo:submit-start", { + target: this.formElement, + detail: { formSubmission: this } + }); + this.delegate.formSubmissionStarted(this); + } + + requestPreventedHandlingResponse(request, response) { + prefetchCache.clear(); + + this.result = { success: response.succeeded, fetchResponse: response }; + } + + requestSucceededWithResponse(request, response) { + if (response.clientError || response.serverError) { + this.delegate.formSubmissionFailedWithResponse(this, response); + return + } + + prefetchCache.clear(); + + if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { + const error = new Error("Form responses must redirect to another location"); + this.delegate.formSubmissionErrored(this, error); + } else { + this.state = FormSubmissionState.receiving; + this.result = { success: true, fetchResponse: response }; + this.delegate.formSubmissionSucceededWithResponse(this, response); + } + } + + requestFailedWithResponse(request, response) { + this.result = { success: false, fetchResponse: response }; + this.delegate.formSubmissionFailedWithResponse(this, response); + } + + requestErrored(request, error) { + this.result = { success: false, error }; + this.delegate.formSubmissionErrored(this, error); + } + + requestFinished(_request) { + this.state = FormSubmissionState.stopped; + this.submitter?.removeAttribute("disabled"); + this.resetSubmitterText(); + clearBusyState(this.formElement); + dispatch("turbo:submit-end", { + target: this.formElement, + detail: { formSubmission: this, ...this.result } + }); + this.delegate.formSubmissionFinished(this); + } + + // Private + + setSubmitsWith() { + if (!this.submitter || !this.submitsWith) return + + if (this.submitter.matches("button")) { + this.originalSubmitText = this.submitter.innerHTML; + this.submitter.innerHTML = this.submitsWith; + } else if (this.submitter.matches("input")) { + const input = this.submitter; + this.originalSubmitText = input.value; + input.value = this.submitsWith; + } + } + + resetSubmitterText() { + if (!this.submitter || !this.originalSubmitText) return + + if (this.submitter.matches("button")) { + this.submitter.innerHTML = this.originalSubmitText; + } else if (this.submitter.matches("input")) { + const input = this.submitter; + input.value = this.originalSubmitText; + } + } + + requestMustRedirect(request) { + return !request.isSafe && this.mustRedirect + } + + requestAcceptsTurboStreamResponse(request) { + return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement) + } + + get submitsWith() { + return this.submitter?.getAttribute("data-turbo-submits-with") + } +} + +function buildFormData(formElement, submitter) { + const formData = new FormData(formElement); + const name = submitter?.getAttribute("name"); + const value = submitter?.getAttribute("value"); + + if (name) { + formData.append(name, value || ""); + } + + return formData +} + +function getCookieValue(cookieName) { + if (cookieName != null) { + const cookies = document.cookie ? document.cookie.split("; ") : []; + const cookie = cookies.find((cookie) => cookie.startsWith(cookieName)); + if (cookie) { + const value = cookie.split("=").slice(1).join("="); + return value ? decodeURIComponent(value) : undefined + } + } +} + +function responseSucceededWithoutRedirect(response) { + return response.statusCode == 200 && !response.redirected +} + +function getFormAction(formElement, submitter) { + const formElementAction = typeof formElement.action === "string" ? formElement.action : null; + + if (submitter?.hasAttribute("formaction")) { + return submitter.getAttribute("formaction") || "" + } else { + return formElement.getAttribute("action") || formElementAction || "" + } +} + +function getAction(formAction, fetchMethod) { + const action = expandURL(formAction); + + if (isSafe(fetchMethod)) { + action.search = ""; + } + + return action +} + +function getMethod(formElement, submitter) { + const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; + return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get +} + +function getEnctype(formElement, submitter) { + return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype) +} + +class Snapshot { + constructor(element) { + this.element = element; + } + + get activeElement() { + return this.element.ownerDocument.activeElement + } + + get children() { + return [...this.element.children] + } + + hasAnchor(anchor) { + return this.getElementForAnchor(anchor) != null + } + + getElementForAnchor(anchor) { + return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null + } + + get isConnected() { + return this.element.isConnected + } + + get firstAutofocusableElement() { + return queryAutofocusableElement(this.element) + } + + get permanentElements() { + return queryPermanentElementsAll(this.element) + } + + getPermanentElementById(id) { + return getPermanentElementById(this.element, id) + } + + getPermanentElementMapForSnapshot(snapshot) { + const permanentElementMap = {}; + + for (const currentPermanentElement of this.permanentElements) { + const { id } = currentPermanentElement; + const newPermanentElement = snapshot.getPermanentElementById(id); + if (newPermanentElement) { + permanentElementMap[id] = [currentPermanentElement, newPermanentElement]; + } + } + + return permanentElementMap + } +} + +function getPermanentElementById(node, id) { + return node.querySelector(`#${id}[data-turbo-permanent]`) +} + +function queryPermanentElementsAll(node) { + return node.querySelectorAll("[id][data-turbo-permanent]") +} + +class FormSubmitObserver { + started = false + + constructor(delegate, eventTarget) { + this.delegate = delegate; + this.eventTarget = eventTarget; + } + + start() { + if (!this.started) { + this.eventTarget.addEventListener("submit", this.submitCaptured, true); + this.started = true; + } + } + + stop() { + if (this.started) { + this.eventTarget.removeEventListener("submit", this.submitCaptured, true); + this.started = false; + } + } + + submitCaptured = () => { + this.eventTarget.removeEventListener("submit", this.submitBubbled, false); + this.eventTarget.addEventListener("submit", this.submitBubbled, false); + } + + submitBubbled = (event) => { + if (!event.defaultPrevented) { + const form = event.target instanceof HTMLFormElement ? event.target : undefined; + const submitter = event.submitter || undefined; + + if ( + form && + submissionDoesNotDismissDialog(form, submitter) && + submissionDoesNotTargetIFrame(form, submitter) && + this.delegate.willSubmitForm(form, submitter) + ) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.delegate.formSubmitted(form, submitter); + } + } + } +} + +function submissionDoesNotDismissDialog(form, submitter) { + const method = submitter?.getAttribute("formmethod") || form.getAttribute("method"); + + return method != "dialog" +} + +function submissionDoesNotTargetIFrame(form, submitter) { + if (submitter?.hasAttribute("formtarget") || form.hasAttribute("target")) { + const target = submitter?.getAttribute("formtarget") || form.target; + + for (const element of document.getElementsByName(target)) { + if (element instanceof HTMLIFrameElement) return false + } + + return true + } else { + return true + } +} + +class View { + #resolveRenderPromise = (_value) => {} + #resolveInterceptionPromise = (_value) => {} + + constructor(delegate, element) { + this.delegate = delegate; + this.element = element; + } + + // Scrolling + + scrollToAnchor(anchor) { + const element = this.snapshot.getElementForAnchor(anchor); + if (element) { + this.scrollToElement(element); + this.focusElement(element); + } else { + this.scrollToPosition({ x: 0, y: 0 }); + } + } + + scrollToAnchorFromLocation(location) { + this.scrollToAnchor(getAnchor(location)); + } + + scrollToElement(element) { + element.scrollIntoView(); + } + + focusElement(element) { + if (element instanceof HTMLElement) { + if (element.hasAttribute("tabindex")) { + element.focus(); + } else { + element.setAttribute("tabindex", "-1"); + element.focus(); + element.removeAttribute("tabindex"); + } + } + } + + scrollToPosition({ x, y }) { + this.scrollRoot.scrollTo(x, y); + } + + scrollToTop() { + this.scrollToPosition({ x: 0, y: 0 }); + } + + get scrollRoot() { + return window + } + + // Rendering + + async render(renderer) { + const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer; + + // A workaround to ignore tracked element mismatch reloads when performing + // a promoted Visit from a frame navigation + const shouldInvalidate = willRender; + + if (shouldRender) { + try { + this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve)); + this.renderer = renderer; + await this.prepareToRenderSnapshot(renderer); + + const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve)); + const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod }; + const immediateRender = this.delegate.allowsImmediateRender(snapshot, options); + if (!immediateRender) await renderInterception; + + await this.renderSnapshot(renderer); + this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod); + this.delegate.preloadOnLoadLinksForView(this.element); + this.finishRenderingSnapshot(renderer); + } finally { + delete this.renderer; + this.#resolveRenderPromise(undefined); + delete this.renderPromise; + } + } else if (shouldInvalidate) { + this.invalidate(renderer.reloadReason); + } + } + + invalidate(reason) { + this.delegate.viewInvalidated(reason); + } + + async prepareToRenderSnapshot(renderer) { + this.markAsPreview(renderer.isPreview); + await renderer.prepareToRender(); + } + + markAsPreview(isPreview) { + if (isPreview) { + this.element.setAttribute("data-turbo-preview", ""); + } else { + this.element.removeAttribute("data-turbo-preview"); + } + } + + markVisitDirection(direction) { + this.element.setAttribute("data-turbo-visit-direction", direction); + } + + unmarkVisitDirection() { + this.element.removeAttribute("data-turbo-visit-direction"); + } + + async renderSnapshot(renderer) { + await renderer.render(); + } + + finishRenderingSnapshot(renderer) { + renderer.finishRendering(); + } +} + +class FrameView extends View { + missing() { + this.element.innerHTML = `Content missing`; + } + + get snapshot() { + return new Snapshot(this.element) + } +} + +class LinkInterceptor { + constructor(delegate, element) { + this.delegate = delegate; + this.element = element; + } + + start() { + this.element.addEventListener("click", this.clickBubbled); + document.addEventListener("turbo:click", this.linkClicked); + document.addEventListener("turbo:before-visit", this.willVisit); + } + + stop() { + this.element.removeEventListener("click", this.clickBubbled); + document.removeEventListener("turbo:click", this.linkClicked); + document.removeEventListener("turbo:before-visit", this.willVisit); + } + + clickBubbled = (event) => { + if (this.respondsToEventTarget(event.target)) { + this.clickEvent = event; + } else { + delete this.clickEvent; + } + } + + linkClicked = (event) => { + if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) { + if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { + this.clickEvent.preventDefault(); + event.preventDefault(); + this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); + } + } + delete this.clickEvent; + } + + willVisit = (_event) => { + delete this.clickEvent; + } + + respondsToEventTarget(target) { + const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; + return element && element.closest("turbo-frame, html") == this.element + } +} + +class LinkClickObserver { + started = false + + constructor(delegate, eventTarget) { + this.delegate = delegate; + this.eventTarget = eventTarget; + } + + start() { + if (!this.started) { + this.eventTarget.addEventListener("click", this.clickCaptured, true); + this.started = true; + } + } + + stop() { + if (this.started) { + this.eventTarget.removeEventListener("click", this.clickCaptured, true); + this.started = false; + } + } + + clickCaptured = () => { + this.eventTarget.removeEventListener("click", this.clickBubbled, false); + this.eventTarget.addEventListener("click", this.clickBubbled, false); + } + + clickBubbled = (event) => { + if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { + const target = (event.composedPath && event.composedPath()[0]) || event.target; + const link = findLinkFromClickTarget(target); + if (link && doesNotTargetIFrame(link)) { + const location = getLocationForLink(link); + if (this.delegate.willFollowLinkToLocation(link, location, event)) { + event.preventDefault(); + this.delegate.followedLinkToLocation(link, location); + } + } + } + } + + clickEventIsSignificant(event) { + return !( + (event.target && event.target.isContentEditable) || + event.defaultPrevented || + event.which > 1 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + } +} + +class FormLinkClickObserver { + constructor(delegate, element) { + this.delegate = delegate; + this.linkInterceptor = new LinkClickObserver(this, element); + } + + start() { + this.linkInterceptor.start(); + } + + stop() { + this.linkInterceptor.stop(); + } + + // Link hover observer delegate + + canPrefetchRequestToLocation(link, location) { + return false + } + + prefetchAndCacheRequestToLocation(link, location) { + return + } + + // Link click observer delegate + + willFollowLinkToLocation(link, location, originalEvent) { + return ( + this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) && + (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream")) + ) + } + + followedLinkToLocation(link, location) { + const form = document.createElement("form"); + + const type = "hidden"; + for (const [name, value] of location.searchParams) { + form.append(Object.assign(document.createElement("input"), { type, name, value })); + } + + const action = Object.assign(location, { search: "" }); + form.setAttribute("data-turbo", "true"); + form.setAttribute("action", action.href); + form.setAttribute("hidden", ""); + + const method = link.getAttribute("data-turbo-method"); + if (method) form.setAttribute("method", method); + + const turboFrame = link.getAttribute("data-turbo-frame"); + if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame); + + const turboAction = getVisitAction(link); + if (turboAction) form.setAttribute("data-turbo-action", turboAction); + + const turboConfirm = link.getAttribute("data-turbo-confirm"); + if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm); + + const turboStream = link.hasAttribute("data-turbo-stream"); + if (turboStream) form.setAttribute("data-turbo-stream", ""); + + this.delegate.submittedFormLinkToLocation(link, location, form); + + document.body.appendChild(form); + form.addEventListener("turbo:submit-end", () => form.remove(), { once: true }); + requestAnimationFrame(() => form.requestSubmit()); + } +} + +class Bardo { + static async preservingPermanentElements(delegate, permanentElementMap, callback) { + const bardo = new this(delegate, permanentElementMap); + bardo.enter(); + await callback(); + bardo.leave(); + } + + constructor(delegate, permanentElementMap) { + this.delegate = delegate; + this.permanentElementMap = permanentElementMap; + } + + enter() { + for (const id in this.permanentElementMap) { + const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id]; + this.delegate.enteringBardo(currentPermanentElement, newPermanentElement); + this.replaceNewPermanentElementWithPlaceholder(newPermanentElement); + } + } + + leave() { + for (const id in this.permanentElementMap) { + const [currentPermanentElement] = this.permanentElementMap[id]; + this.replaceCurrentPermanentElementWithClone(currentPermanentElement); + this.replacePlaceholderWithPermanentElement(currentPermanentElement); + this.delegate.leavingBardo(currentPermanentElement); + } + } + + replaceNewPermanentElementWithPlaceholder(permanentElement) { + const placeholder = createPlaceholderForPermanentElement(permanentElement); + permanentElement.replaceWith(placeholder); + } + + replaceCurrentPermanentElementWithClone(permanentElement) { + const clone = permanentElement.cloneNode(true); + permanentElement.replaceWith(clone); + } + + replacePlaceholderWithPermanentElement(permanentElement) { + const placeholder = this.getPlaceholderById(permanentElement.id); + placeholder?.replaceWith(permanentElement); + } + + getPlaceholderById(id) { + return this.placeholders.find((element) => element.content == id) + } + + get placeholders() { + return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")] + } +} + +function createPlaceholderForPermanentElement(permanentElement) { + const element = document.createElement("meta"); + element.setAttribute("name", "turbo-permanent-placeholder"); + element.setAttribute("content", permanentElement.id); + return element +} + +class Renderer { + #activeElement = null + + constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + this.currentSnapshot = currentSnapshot; + this.newSnapshot = newSnapshot; + this.isPreview = isPreview; + this.willRender = willRender; + this.renderElement = renderElement; + this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject })); + } + + get shouldRender() { + return true + } + + get reloadReason() { + return + } + + prepareToRender() { + return + } + + render() { + // Abstract method + } + + finishRendering() { + if (this.resolvingFunctions) { + this.resolvingFunctions.resolve(); + delete this.resolvingFunctions; + } + } + + async preservingPermanentElements(callback) { + await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback); + } + + focusFirstAutofocusableElement() { + const element = this.connectedSnapshot.firstAutofocusableElement; + if (element) { + element.focus(); + } + } + + // Bardo delegate + + enteringBardo(currentPermanentElement) { + if (this.#activeElement) return + + if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { + this.#activeElement = this.currentSnapshot.activeElement; + } + } + + leavingBardo(currentPermanentElement) { + if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { + this.#activeElement.focus(); + + this.#activeElement = null; + } + } + + get connectedSnapshot() { + return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot + } + + get currentElement() { + return this.currentSnapshot.element + } + + get newElement() { + return this.newSnapshot.element + } + + get permanentElementMap() { + return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot) + } + + get renderMethod() { + return "replace" + } +} + +class FrameRenderer extends Renderer { + static renderElement(currentElement, newElement) { + const destinationRange = document.createRange(); + destinationRange.selectNodeContents(currentElement); + destinationRange.deleteContents(); + + const frameElement = newElement; + const sourceRange = frameElement.ownerDocument?.createRange(); + if (sourceRange) { + sourceRange.selectNodeContents(frameElement); + currentElement.appendChild(sourceRange.extractContents()); + } + } + + constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { + super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); + this.delegate = delegate; + } + + get shouldRender() { + return true + } + + async render() { + await nextRepaint(); + this.preservingPermanentElements(() => { + this.loadFrameElement(); + }); + this.scrollFrameIntoView(); + await nextRepaint(); + this.focusFirstAutofocusableElement(); + await nextRepaint(); + this.activateScriptElements(); + } + + loadFrameElement() { + this.delegate.willRenderFrame(this.currentElement, this.newElement); + this.renderElement(this.currentElement, this.newElement); + } + + scrollFrameIntoView() { + if (this.currentElement.autoscroll || this.newElement.autoscroll) { + const element = this.currentElement.firstElementChild; + const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); + const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); + + if (element) { + element.scrollIntoView({ block, behavior }); + return true + } + } + return false + } + + activateScriptElements() { + for (const inertScriptElement of this.newScriptElements) { + const activatedScriptElement = activateScriptElement(inertScriptElement); + inertScriptElement.replaceWith(activatedScriptElement); + } + } + + get newScriptElements() { + return this.currentElement.querySelectorAll("script") + } +} + +function readScrollLogicalPosition(value, defaultValue) { + if (value == "end" || value == "start" || value == "center" || value == "nearest") { + return value + } else { + return defaultValue + } +} + +function readScrollBehavior(value, defaultValue) { + if (value == "auto" || value == "smooth") { + return value + } else { + return defaultValue + } +} + +class ProgressBar { + static animationDuration = 300 /*ms*/ + + static get defaultCSS() { + return unindent` + .turbo-progress-bar { + position: fixed; + display: block; + top: 0; + left: 0; + height: 3px; + background: #0076ff; + z-index: 2147483647; + transition: + width ${ProgressBar.animationDuration}ms ease-out, + opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in; + transform: translate3d(0, 0, 0); + } + ` + } + + hiding = false + value = 0 + visible = false + + constructor() { + this.stylesheetElement = this.createStylesheetElement(); + this.progressElement = this.createProgressElement(); + this.installStylesheetElement(); + this.setValue(0); + } + + show() { + if (!this.visible) { + this.visible = true; + this.installProgressElement(); + this.startTrickling(); + } + } + + hide() { + if (this.visible && !this.hiding) { + this.hiding = true; + this.fadeProgressElement(() => { + this.uninstallProgressElement(); + this.stopTrickling(); + this.visible = false; + this.hiding = false; + }); + } + } + + setValue(value) { + this.value = value; + this.refresh(); + } + + // Private + + installStylesheetElement() { + document.head.insertBefore(this.stylesheetElement, document.head.firstChild); + } + + installProgressElement() { + this.progressElement.style.width = "0"; + this.progressElement.style.opacity = "1"; + document.documentElement.insertBefore(this.progressElement, document.body); + this.refresh(); + } + + fadeProgressElement(callback) { + this.progressElement.style.opacity = "0"; + setTimeout(callback, ProgressBar.animationDuration * 1.5); + } + + uninstallProgressElement() { + if (this.progressElement.parentNode) { + document.documentElement.removeChild(this.progressElement); + } + } + + startTrickling() { + if (!this.trickleInterval) { + this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration); + } + } + + stopTrickling() { + window.clearInterval(this.trickleInterval); + delete this.trickleInterval; + } + + trickle = () => { + this.setValue(this.value + Math.random() / 100); + } + + refresh() { + requestAnimationFrame(() => { + this.progressElement.style.width = `${10 + this.value * 90}%`; + }); + } + + createStylesheetElement() { + const element = document.createElement("style"); + element.type = "text/css"; + element.textContent = ProgressBar.defaultCSS; + if (this.cspNonce) { + element.nonce = this.cspNonce; + } + return element + } + + createProgressElement() { + const element = document.createElement("div"); + element.className = "turbo-progress-bar"; + return element + } + + get cspNonce() { + return getMetaContent("csp-nonce") + } +} + +class HeadSnapshot extends Snapshot { + detailsByOuterHTML = this.children + .filter((element) => !elementIsNoscript(element)) + .map((element) => elementWithoutNonce(element)) + .reduce((result, element) => { + const { outerHTML } = element; + const details = + outerHTML in result + ? result[outerHTML] + : { + type: elementType(element), + tracked: elementIsTracked(element), + elements: [] + }; + return { + ...result, + [outerHTML]: { + ...details, + elements: [...details.elements, element] + } + } + }, {}) + + get trackedElementSignature() { + return Object.keys(this.detailsByOuterHTML) + .filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked) + .join("") + } + + getScriptElementsNotInSnapshot(snapshot) { + return this.getElementsMatchingTypeNotInSnapshot("script", snapshot) + } + + getStylesheetElementsNotInSnapshot(snapshot) { + return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot) + } + + getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) { + return Object.keys(this.detailsByOuterHTML) + .filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)) + .map((outerHTML) => this.detailsByOuterHTML[outerHTML]) + .filter(({ type }) => type == matchedType) + .map(({ elements: [element] }) => element) + } + + get provisionalElements() { + return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { + const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML]; + if (type == null && !tracked) { + return [...result, ...elements] + } else if (elements.length > 1) { + return [...result, ...elements.slice(1)] + } else { + return result + } + }, []) + } + + getMetaValue(name) { + const element = this.findMetaElementByName(name); + return element ? element.getAttribute("content") : null + } + + findMetaElementByName(name) { + return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { + const { + elements: [element] + } = this.detailsByOuterHTML[outerHTML]; + return elementIsMetaElementWithName(element, name) ? element : result + }, undefined | undefined) + } +} + +function elementType(element) { + if (elementIsScript(element)) { + return "script" + } else if (elementIsStylesheet(element)) { + return "stylesheet" + } +} + +function elementIsTracked(element) { + return element.getAttribute("data-turbo-track") == "reload" +} + +function elementIsScript(element) { + const tagName = element.localName; + return tagName == "script" +} + +function elementIsNoscript(element) { + const tagName = element.localName; + return tagName == "noscript" +} + +function elementIsStylesheet(element) { + const tagName = element.localName; + return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet") +} + +function elementIsMetaElementWithName(element, name) { + const tagName = element.localName; + return tagName == "meta" && element.getAttribute("name") == name +} + +function elementWithoutNonce(element) { + if (element.hasAttribute("nonce")) { + element.setAttribute("nonce", ""); + } + + return element +} + +class PageSnapshot extends Snapshot { + static fromHTMLString(html = "") { + return this.fromDocument(parseHTMLDocument(html)) + } + + static fromElement(element) { + return this.fromDocument(element.ownerDocument) + } + + static fromDocument({ documentElement, body, head }) { + return new this(documentElement, body, new HeadSnapshot(head)) + } + + constructor(documentElement, body, headSnapshot) { + super(body); + this.documentElement = documentElement; + this.headSnapshot = headSnapshot; + } + + clone() { + const clonedElement = this.element.cloneNode(true); + + const selectElements = this.element.querySelectorAll("select"); + const clonedSelectElements = clonedElement.querySelectorAll("select"); + + for (const [index, source] of selectElements.entries()) { + const clone = clonedSelectElements[index]; + for (const option of clone.selectedOptions) option.selected = false; + for (const option of source.selectedOptions) clone.options[option.index].selected = true; + } + + for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { + clonedPasswordInput.value = ""; + } + + return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot) + } + + get lang() { + return this.documentElement.getAttribute("lang") + } + + get headElement() { + return this.headSnapshot.element + } + + get rootLocation() { + const root = this.getSetting("root") ?? "/"; + return expandURL(root) + } + + get cacheControlValue() { + return this.getSetting("cache-control") + } + + get isPreviewable() { + return this.cacheControlValue != "no-preview" + } + + get isCacheable() { + return this.cacheControlValue != "no-cache" + } + + get isVisitable() { + return this.getSetting("visit-control") != "reload" + } + + get prefersViewTransitions() { + return this.headSnapshot.getMetaValue("view-transition") === "same-origin" + } + + get shouldMorphPage() { + return this.getSetting("refresh-method") === "morph" + } + + get shouldPreserveScrollPosition() { + return this.getSetting("refresh-scroll") === "preserve" + } + + // Private + + getSetting(name) { + return this.headSnapshot.getMetaValue(`turbo-${name}`) + } +} + +class ViewTransitioner { + #viewTransitionStarted = false + #lastOperation = Promise.resolve() + + renderChange(useViewTransition, render) { + if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) { + this.#viewTransitionStarted = true; + this.#lastOperation = this.#lastOperation.then(async () => { + await document.startViewTransition(render).finished; + }); + } else { + this.#lastOperation = this.#lastOperation.then(render); + } + + return this.#lastOperation + } + + get viewTransitionsAvailable() { + return document.startViewTransition + } +} + +const defaultOptions = { + action: "advance", + historyChanged: false, + visitCachedSnapshot: () => {}, + willRender: true, + updateHistory: true, + shouldCacheSnapshot: true, + acceptsStreamResponse: false +}; + +const TimingMetric = { + visitStart: "visitStart", + requestStart: "requestStart", + requestEnd: "requestEnd", + visitEnd: "visitEnd" +}; + +const VisitState = { + initialized: "initialized", + started: "started", + canceled: "canceled", + failed: "failed", + completed: "completed" +}; + +const SystemStatusCode = { + networkFailure: 0, + timeoutFailure: -1, + contentTypeMismatch: -2 +}; + +const Direction = { + advance: "forward", + restore: "back", + replace: "none" +}; + +class Visit { + identifier = uuid() // Required by turbo-ios + timingMetrics = {} + + followedRedirect = false + historyChanged = false + scrolled = false + shouldCacheSnapshot = true + acceptsStreamResponse = false + snapshotCached = false + state = VisitState.initialized + viewTransitioner = new ViewTransitioner() + + constructor(delegate, location, restorationIdentifier, options = {}) { + this.delegate = delegate; + this.location = location; + this.restorationIdentifier = restorationIdentifier || uuid(); + + const { + action, + historyChanged, + referrer, + snapshot, + snapshotHTML, + response, + visitCachedSnapshot, + willRender, + updateHistory, + shouldCacheSnapshot, + acceptsStreamResponse, + direction + } = { + ...defaultOptions, + ...options + }; + this.action = action; + this.historyChanged = historyChanged; + this.referrer = referrer; + this.snapshot = snapshot; + this.snapshotHTML = snapshotHTML; + this.response = response; + this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action); + this.isPageRefresh = this.view.isPageRefresh(this); + this.visitCachedSnapshot = visitCachedSnapshot; + this.willRender = willRender; + this.updateHistory = updateHistory; + this.scrolled = !willRender; + this.shouldCacheSnapshot = shouldCacheSnapshot; + this.acceptsStreamResponse = acceptsStreamResponse; + this.direction = direction || Direction[action]; + } + + get adapter() { + return this.delegate.adapter + } + + get view() { + return this.delegate.view + } + + get history() { + return this.delegate.history + } + + get restorationData() { + return this.history.getRestorationDataForIdentifier(this.restorationIdentifier) + } + + get silent() { + return this.isSamePage + } + + start() { + if (this.state == VisitState.initialized) { + this.recordTimingMetric(TimingMetric.visitStart); + this.state = VisitState.started; + this.adapter.visitStarted(this); + this.delegate.visitStarted(this); + } + } + + cancel() { + if (this.state == VisitState.started) { + if (this.request) { + this.request.cancel(); + } + this.cancelRender(); + this.state = VisitState.canceled; + } + } + + complete() { + if (this.state == VisitState.started) { + this.recordTimingMetric(TimingMetric.visitEnd); + this.adapter.visitCompleted(this); + this.state = VisitState.completed; + this.followRedirect(); + + if (!this.followedRedirect) { + this.delegate.visitCompleted(this); + } + } + } + + fail() { + if (this.state == VisitState.started) { + this.state = VisitState.failed; + this.adapter.visitFailed(this); + this.delegate.visitCompleted(this); + } + } + + changeHistory() { + if (!this.historyChanged && this.updateHistory) { + const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action; + const method = getHistoryMethodForAction(actionForHistory); + this.history.update(method, this.location, this.restorationIdentifier); + this.historyChanged = true; + } + } + + issueRequest() { + if (this.hasPreloadedResponse()) { + this.simulateRequest(); + } else if (this.shouldIssueRequest() && !this.request) { + this.request = new FetchRequest(this, FetchMethod.get, this.location); + this.request.perform(); + } + } + + simulateRequest() { + if (this.response) { + this.startRequest(); + this.recordResponse(); + this.finishRequest(); + } + } + + startRequest() { + this.recordTimingMetric(TimingMetric.requestStart); + this.adapter.visitRequestStarted(this); + } + + recordResponse(response = this.response) { + this.response = response; + if (response) { + const { statusCode } = response; + if (isSuccessful(statusCode)) { + this.adapter.visitRequestCompleted(this); + } else { + this.adapter.visitRequestFailedWithStatusCode(this, statusCode); + } + } + } + + finishRequest() { + this.recordTimingMetric(TimingMetric.requestEnd); + this.adapter.visitRequestFinished(this); + } + + loadResponse() { + if (this.response) { + const { statusCode, responseHTML } = this.response; + this.render(async () => { + if (this.shouldCacheSnapshot) this.cacheSnapshot(); + if (this.view.renderPromise) await this.view.renderPromise; + + if (isSuccessful(statusCode) && responseHTML != null) { + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + await this.renderPageSnapshot(snapshot, false); + + this.adapter.visitRendered(this); + this.complete(); + } else { + await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this); + this.adapter.visitRendered(this); + this.fail(); + } + }); + } + } + + getCachedSnapshot() { + const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot(); + + if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { + if (this.action == "restore" || snapshot.isPreviewable) { + return snapshot + } + } + } + + getPreloadedSnapshot() { + if (this.snapshotHTML) { + return PageSnapshot.fromHTMLString(this.snapshotHTML) + } + } + + hasCachedSnapshot() { + return this.getCachedSnapshot() != null + } + + loadCachedSnapshot() { + const snapshot = this.getCachedSnapshot(); + if (snapshot) { + const isPreview = this.shouldIssueRequest(); + this.render(async () => { + this.cacheSnapshot(); + if (this.isSamePage || this.isPageRefresh) { + this.adapter.visitRendered(this); + } else { + if (this.view.renderPromise) await this.view.renderPromise; + + await this.renderPageSnapshot(snapshot, isPreview); + + this.adapter.visitRendered(this); + if (!isPreview) { + this.complete(); + } + } + }); + } + } + + followRedirect() { + if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { + this.adapter.visitProposedToLocation(this.redirectedToLocation, { + action: "replace", + response: this.response, + shouldCacheSnapshot: false, + willRender: false + }); + this.followedRedirect = true; + } + } + + goToSamePageAnchor() { + if (this.isSamePage) { + this.render(async () => { + this.cacheSnapshot(); + this.performScroll(); + this.changeHistory(); + this.adapter.visitRendered(this); + }); + } + } + + // Fetch request delegate + + prepareRequest(request) { + if (this.acceptsStreamResponse) { + request.acceptResponseType(StreamMessage.contentType); + } + } + + requestStarted() { + this.startRequest(); + } + + requestPreventedHandlingResponse(_request, _response) {} + + async requestSucceededWithResponse(request, response) { + const responseHTML = await response.responseHTML; + const { redirected, statusCode } = response; + if (responseHTML == undefined) { + this.recordResponse({ + statusCode: SystemStatusCode.contentTypeMismatch, + redirected + }); + } else { + this.redirectedToLocation = response.redirected ? response.location : undefined; + this.recordResponse({ statusCode: statusCode, responseHTML, redirected }); + } + } + + async requestFailedWithResponse(request, response) { + const responseHTML = await response.responseHTML; + const { redirected, statusCode } = response; + if (responseHTML == undefined) { + this.recordResponse({ + statusCode: SystemStatusCode.contentTypeMismatch, + redirected + }); + } else { + this.recordResponse({ statusCode: statusCode, responseHTML, redirected }); + } + } + + requestErrored(_request, _error) { + this.recordResponse({ + statusCode: SystemStatusCode.networkFailure, + redirected: false + }); + } + + requestFinished() { + this.finishRequest(); + } + + // Scrolling + + performScroll() { + if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { + if (this.action == "restore") { + this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop(); + } else { + this.scrollToAnchor() || this.view.scrollToTop(); + } + if (this.isSamePage) { + this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location); + } + + this.scrolled = true; + } + } + + scrollToRestoredPosition() { + const { scrollPosition } = this.restorationData; + if (scrollPosition) { + this.view.scrollToPosition(scrollPosition); + return true + } + } + + scrollToAnchor() { + const anchor = getAnchor(this.location); + if (anchor != null) { + this.view.scrollToAnchor(anchor); + return true + } + } + + // Instrumentation + + recordTimingMetric(metric) { + this.timingMetrics[metric] = new Date().getTime(); + } + + getTimingMetrics() { + return { ...this.timingMetrics } + } + + // Private + + getHistoryMethodForAction(action) { + switch (action) { + case "replace": + return history.replaceState + case "advance": + case "restore": + return history.pushState + } + } + + hasPreloadedResponse() { + return typeof this.response == "object" + } + + shouldIssueRequest() { + if (this.isSamePage) { + return false + } else if (this.action == "restore") { + return !this.hasCachedSnapshot() + } else { + return this.willRender + } + } + + cacheSnapshot() { + if (!this.snapshotCached) { + this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)); + this.snapshotCached = true; + } + } + + async render(callback) { + this.cancelRender(); + this.frame = await nextRepaint(); + await callback(); + delete this.frame; + } + + async renderPageSnapshot(snapshot, isPreview) { + await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { + await this.view.renderPage(snapshot, isPreview, this.willRender, this); + this.performScroll(); + }); + } + + cancelRender() { + if (this.frame) { + cancelAnimationFrame(this.frame); + delete this.frame; + } + } +} + +function isSuccessful(statusCode) { + return statusCode >= 200 && statusCode < 300 +} + +class BrowserAdapter { + progressBar = new ProgressBar() + + constructor(session) { + this.session = session; + } + + visitProposedToLocation(location, options) { + if (locationIsVisitable(location, this.navigator.rootLocation)) { + this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options); + } else { + window.location.href = location.toString(); + } + } + + visitStarted(visit) { + this.location = visit.location; + visit.loadCachedSnapshot(); + visit.issueRequest(); + visit.goToSamePageAnchor(); + } + + visitRequestStarted(visit) { + this.progressBar.setValue(0); + if (visit.hasCachedSnapshot() || visit.action != "restore") { + this.showVisitProgressBarAfterDelay(); + } else { + this.showProgressBar(); + } + } + + visitRequestCompleted(visit) { + visit.loadResponse(); + } + + visitRequestFailedWithStatusCode(visit, statusCode) { + switch (statusCode) { + case SystemStatusCode.networkFailure: + case SystemStatusCode.timeoutFailure: + case SystemStatusCode.contentTypeMismatch: + return this.reload({ + reason: "request_failed", + context: { + statusCode + } + }) + default: + return visit.loadResponse() + } + } + + visitRequestFinished(_visit) {} + + visitCompleted(_visit) { + this.progressBar.setValue(1); + this.hideVisitProgressBar(); + } + + pageInvalidated(reason) { + this.reload(reason); + } + + visitFailed(_visit) { + this.progressBar.setValue(1); + this.hideVisitProgressBar(); + } + + visitRendered(_visit) {} + + // Form Submission Delegate + + formSubmissionStarted(_formSubmission) { + this.progressBar.setValue(0); + this.showFormProgressBarAfterDelay(); + } + + formSubmissionFinished(_formSubmission) { + this.progressBar.setValue(1); + this.hideFormProgressBar(); + } + + // Private + + showVisitProgressBarAfterDelay() { + this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); + } + + hideVisitProgressBar() { + this.progressBar.hide(); + if (this.visitProgressBarTimeout != null) { + window.clearTimeout(this.visitProgressBarTimeout); + delete this.visitProgressBarTimeout; + } + } + + showFormProgressBarAfterDelay() { + if (this.formProgressBarTimeout == null) { + this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); + } + } + + hideFormProgressBar() { + this.progressBar.hide(); + if (this.formProgressBarTimeout != null) { + window.clearTimeout(this.formProgressBarTimeout); + delete this.formProgressBarTimeout; + } + } + + showProgressBar = () => { + this.progressBar.show(); + } + + reload(reason) { + dispatch("turbo:reload", { detail: reason }); + + window.location.href = this.location?.toString() || window.location.href; + } + + get navigator() { + return this.session.navigator + } +} + +class CacheObserver { + selector = "[data-turbo-temporary]" + deprecatedSelector = "[data-turbo-cache=false]" + + started = false + + start() { + if (!this.started) { + this.started = true; + addEventListener("turbo:before-cache", this.removeTemporaryElements, false); + } + } + + stop() { + if (this.started) { + this.started = false; + removeEventListener("turbo:before-cache", this.removeTemporaryElements, false); + } + } + + removeTemporaryElements = (_event) => { + for (const element of this.temporaryElements) { + element.remove(); + } + } + + get temporaryElements() { + return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation] + } + + get temporaryElementsWithDeprecation() { + const elements = document.querySelectorAll(this.deprecatedSelector); + + if (elements.length) { + console.warn( + `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.` + ); + } + + return [...elements] + } +} + +class FrameRedirector { + constructor(session, element) { + this.session = session; + this.element = element; + this.linkInterceptor = new LinkInterceptor(this, element); + this.formSubmitObserver = new FormSubmitObserver(this, element); + } + + start() { + this.linkInterceptor.start(); + this.formSubmitObserver.start(); + } + + stop() { + this.linkInterceptor.stop(); + this.formSubmitObserver.stop(); + } + + // Link interceptor delegate + + shouldInterceptLinkClick(element, _location, _event) { + return this.#shouldRedirect(element) + } + + linkClickIntercepted(element, url, event) { + const frame = this.#findFrameElement(element); + if (frame) { + frame.delegate.linkClickIntercepted(element, url, event); + } + } + + // Form submit observer delegate + + willSubmitForm(element, submitter) { + return ( + element.closest("turbo-frame") == null && + this.#shouldSubmit(element, submitter) && + this.#shouldRedirect(element, submitter) + ) + } + + formSubmitted(element, submitter) { + const frame = this.#findFrameElement(element, submitter); + if (frame) { + frame.delegate.formSubmitted(element, submitter); + } + } + + #shouldSubmit(form, submitter) { + const action = getAction$1(form, submitter); + const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); + const rootLocation = expandURL(meta?.content ?? "/"); + + return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation) + } + + #shouldRedirect(element, submitter) { + const isNavigatable = + element instanceof HTMLFormElement + ? this.session.submissionIsNavigatable(element, submitter) + : this.session.elementIsNavigatable(element); + + if (isNavigatable) { + const frame = this.#findFrameElement(element, submitter); + return frame ? frame != element.closest("turbo-frame") : false + } else { + return false + } + } + + #findFrameElement(element, submitter) { + const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame"); + if (id && id != "_top") { + const frame = this.element.querySelector(`#${id}:not([disabled])`); + if (frame instanceof FrameElement) { + return frame + } + } + } +} + +class History { + location + restorationIdentifier = uuid() + restorationData = {} + started = false + pageLoaded = false + currentIndex = 0 + + constructor(delegate) { + this.delegate = delegate; + } + + start() { + if (!this.started) { + addEventListener("popstate", this.onPopState, false); + addEventListener("load", this.onPageLoad, false); + this.currentIndex = history.state?.turbo?.restorationIndex || 0; + this.started = true; + this.replace(new URL(window.location.href)); + } + } + + stop() { + if (this.started) { + removeEventListener("popstate", this.onPopState, false); + removeEventListener("load", this.onPageLoad, false); + this.started = false; + } + } + + push(location, restorationIdentifier) { + this.update(history.pushState, location, restorationIdentifier); + } + + replace(location, restorationIdentifier) { + this.update(history.replaceState, location, restorationIdentifier); + } + + update(method, location, restorationIdentifier = uuid()) { + if (method === history.pushState) ++this.currentIndex; + + const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } }; + method.call(history, state, "", location.href); + this.location = location; + this.restorationIdentifier = restorationIdentifier; + } + + // Restoration data + + getRestorationDataForIdentifier(restorationIdentifier) { + return this.restorationData[restorationIdentifier] || {} + } + + updateRestorationData(additionalData) { + const { restorationIdentifier } = this; + const restorationData = this.restorationData[restorationIdentifier]; + this.restorationData[restorationIdentifier] = { + ...restorationData, + ...additionalData + }; + } + + // Scroll restoration + + assumeControlOfScrollRestoration() { + if (!this.previousScrollRestoration) { + this.previousScrollRestoration = history.scrollRestoration ?? "auto"; + history.scrollRestoration = "manual"; + } + } + + relinquishControlOfScrollRestoration() { + if (this.previousScrollRestoration) { + history.scrollRestoration = this.previousScrollRestoration; + delete this.previousScrollRestoration; + } + } + + // Event handlers + + onPopState = (event) => { + if (this.shouldHandlePopState()) { + const { turbo } = event.state || {}; + if (turbo) { + this.location = new URL(window.location.href); + const { restorationIdentifier, restorationIndex } = turbo; + this.restorationIdentifier = restorationIdentifier; + const direction = restorationIndex > this.currentIndex ? "forward" : "back"; + this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); + this.currentIndex = restorationIndex; + } + } + } + + onPageLoad = async (_event) => { + await nextMicrotask(); + this.pageLoaded = true; + } + + // Private + + shouldHandlePopState() { + // Safari dispatches a popstate event after window's load event, ignore it + return this.pageIsLoaded() + } + + pageIsLoaded() { + return this.pageLoaded || document.readyState == "complete" + } +} + +class LinkPrefetchObserver { + started = false + #prefetchedLink = null + + constructor(delegate, eventTarget) { + this.delegate = delegate; + this.eventTarget = eventTarget; + } + + start() { + if (this.started) return + + if (this.eventTarget.readyState === "loading") { + this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true }); + } else { + this.#enable(); + } + } + + stop() { + if (!this.started) return + + this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { + capture: true, + passive: true + }); + this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, { + capture: true, + passive: true + }); + + this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); + this.started = false; + } + + #enable = () => { + this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { + capture: true, + passive: true + }); + this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { + capture: true, + passive: true + }); + + this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); + this.started = true; + } + + #tryToPrefetchRequest = (event) => { + if (getMetaContent("turbo-prefetch") === "false") return + + const target = event.target; + const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])"); + + if (isLink && this.#isPrefetchable(target)) { + const link = target; + const location = getLocationForLink(link); + + if (this.delegate.canPrefetchRequestToLocation(link, location)) { + this.#prefetchedLink = link; + + const fetchRequest = new FetchRequest( + this, + FetchMethod.get, + location, + new URLSearchParams(), + target + ); + + prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl); + } + } + } + + #cancelRequestIfObsolete = (event) => { + if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest(); + } + + #cancelPrefetchRequest = () => { + prefetchCache.clear(); + this.#prefetchedLink = null; + } + + #tryToUsePrefetchedRequest = (event) => { + if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "get") { + const cached = prefetchCache.get(event.detail.url.toString()); + + if (cached) { + // User clicked link, use cache response + event.detail.fetchRequest = cached; + } + + prefetchCache.clear(); + } + } + + prepareRequest(request) { + const link = request.target; + + request.headers["X-Sec-Purpose"] = "prefetch"; + + const turboFrame = link.closest("turbo-frame"); + const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id; + + if (turboFrameTarget && turboFrameTarget !== "_top") { + request.headers["Turbo-Frame"] = turboFrameTarget; + } + } + + // Fetch request interface + + requestSucceededWithResponse() {} + + requestStarted(fetchRequest) {} + + requestErrored(fetchRequest) {} + + requestFinished(fetchRequest) {} + + requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} + + requestFailedWithResponse(fetchRequest, fetchResponse) {} + + get #cacheTtl() { + return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl + } + + #isPrefetchable(link) { + const href = link.getAttribute("href"); + + if (!href) return false + + if (unfetchableLink(link)) return false + if (linkToTheSamePage(link)) return false + if (linkOptsOut(link)) return false + if (nonSafeLink(link)) return false + if (eventPrevented(link)) return false + + return true + } +} + +const unfetchableLink = (link) => { + return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target") +}; + +const linkToTheSamePage = (link) => { + return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#") +}; + +const linkOptsOut = (link) => { + if (link.getAttribute("data-turbo-prefetch") === "false") return true + if (link.getAttribute("data-turbo") === "false") return true + + const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]"); + if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true + + return false +}; + +const nonSafeLink = (link) => { + const turboMethod = link.getAttribute("data-turbo-method"); + if (turboMethod && turboMethod.toLowerCase() !== "get") return true + + if (isUJS(link)) return true + if (link.hasAttribute("data-turbo-confirm")) return true + if (link.hasAttribute("data-turbo-stream")) return true + + return false +}; + +const isUJS = (link) => { + return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method") +}; + +const eventPrevented = (link) => { + const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }); + return event.defaultPrevented +}; + +class Navigator { + constructor(delegate) { + this.delegate = delegate; + } + + proposeVisit(location, options = {}) { + if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) { + this.delegate.visitProposedToLocation(location, options); + } + } + + startVisit(locatable, restorationIdentifier, options = {}) { + this.stop(); + this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { + referrer: this.location, + ...options + }); + this.currentVisit.start(); + } + + submitForm(form, submitter) { + this.stop(); + this.formSubmission = new FormSubmission(this, form, submitter, true); + + this.formSubmission.start(); + } + + stop() { + if (this.formSubmission) { + this.formSubmission.stop(); + delete this.formSubmission; + } + + if (this.currentVisit) { + this.currentVisit.cancel(); + delete this.currentVisit; + } + } + + get adapter() { + return this.delegate.adapter + } + + get view() { + return this.delegate.view + } + + get rootLocation() { + return this.view.snapshot.rootLocation + } + + get history() { + return this.delegate.history + } + + // Form submission delegate + + formSubmissionStarted(formSubmission) { + // Not all adapters implement formSubmissionStarted + if (typeof this.adapter.formSubmissionStarted === "function") { + this.adapter.formSubmissionStarted(formSubmission); + } + } + + async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { + if (formSubmission == this.formSubmission) { + const responseHTML = await fetchResponse.responseHTML; + if (responseHTML) { + const shouldCacheSnapshot = formSubmission.isSafe; + if (!shouldCacheSnapshot) { + this.view.clearSnapshotCache(); + } + + const { statusCode, redirected } = fetchResponse; + const action = this.#getActionForFormSubmission(formSubmission, fetchResponse); + const visitOptions = { + action, + shouldCacheSnapshot, + response: { statusCode, responseHTML, redirected } + }; + this.proposeVisit(fetchResponse.location, visitOptions); + } + } + } + + async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { + const responseHTML = await fetchResponse.responseHTML; + + if (responseHTML) { + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + if (fetchResponse.serverError) { + await this.view.renderError(snapshot, this.currentVisit); + } else { + await this.view.renderPage(snapshot, false, true, this.currentVisit); + } + if(!snapshot.shouldPreserveScrollPosition) { + this.view.scrollToTop(); + } + this.view.clearSnapshotCache(); + } + } + + formSubmissionErrored(formSubmission, error) { + console.error(error); + } + + formSubmissionFinished(formSubmission) { + // Not all adapters implement formSubmissionFinished + if (typeof this.adapter.formSubmissionFinished === "function") { + this.adapter.formSubmissionFinished(formSubmission); + } + } + + // Visit delegate + + visitStarted(visit) { + this.delegate.visitStarted(visit); + } + + visitCompleted(visit) { + this.delegate.visitCompleted(visit); + } + + locationWithActionIsSamePage(location, action) { + const anchor = getAnchor(location); + const currentAnchor = getAnchor(this.view.lastRenderedLocation); + const isRestorationToTop = action === "restore" && typeof anchor === "undefined"; + + return ( + action !== "replace" && + getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) && + (isRestorationToTop || (anchor != null && anchor !== currentAnchor)) + ) + } + + visitScrolledToSamePageLocation(oldURL, newURL) { + this.delegate.visitScrolledToSamePageLocation(oldURL, newURL); + } + + // Visits + + get location() { + return this.history.location + } + + get restorationIdentifier() { + return this.history.restorationIdentifier + } + + #getActionForFormSubmission(formSubmission, fetchResponse) { + const { submitter, formElement } = formSubmission; + return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse) + } + + #getDefaultAction(fetchResponse) { + const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href; + return sameLocationRedirect ? "replace" : "advance" + } +} + +const PageStage = { + initial: 0, + loading: 1, + interactive: 2, + complete: 3 +}; + +class PageObserver { + stage = PageStage.initial + started = false + + constructor(delegate) { + this.delegate = delegate; + } + + start() { + if (!this.started) { + if (this.stage == PageStage.initial) { + this.stage = PageStage.loading; + } + document.addEventListener("readystatechange", this.interpretReadyState, false); + addEventListener("pagehide", this.pageWillUnload, false); + this.started = true; + } + } + + stop() { + if (this.started) { + document.removeEventListener("readystatechange", this.interpretReadyState, false); + removeEventListener("pagehide", this.pageWillUnload, false); + this.started = false; + } + } + + interpretReadyState = () => { + const { readyState } = this; + if (readyState == "interactive") { + this.pageIsInteractive(); + } else if (readyState == "complete") { + this.pageIsComplete(); + } + } + + pageIsInteractive() { + if (this.stage == PageStage.loading) { + this.stage = PageStage.interactive; + this.delegate.pageBecameInteractive(); + } + } + + pageIsComplete() { + this.pageIsInteractive(); + if (this.stage == PageStage.interactive) { + this.stage = PageStage.complete; + this.delegate.pageLoaded(); + } + } + + pageWillUnload = () => { + this.delegate.pageWillUnload(); + } + + get readyState() { + return document.readyState + } +} + +class ScrollObserver { + started = false + + constructor(delegate) { + this.delegate = delegate; + } + + start() { + if (!this.started) { + addEventListener("scroll", this.onScroll, false); + this.onScroll(); + this.started = true; + } + } + + stop() { + if (this.started) { + removeEventListener("scroll", this.onScroll, false); + this.started = false; + } + } + + onScroll = () => { + this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); + } + + // Private + + updatePosition(position) { + this.delegate.scrollPositionChanged(position); + } +} + +class StreamMessageRenderer { + render({ fragment }) { + Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { + withAutofocusFromFragment(fragment, () => { + withPreservedFocus(() => { + document.documentElement.appendChild(fragment); + }); + }); + }); + } + + // Bardo delegate + + enteringBardo(currentPermanentElement, newPermanentElement) { + newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)); + } + + leavingBardo() {} +} + +function getPermanentElementMapForFragment(fragment) { + const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement); + const permanentElementMap = {}; + for (const permanentElementInDocument of permanentElementsInDocument) { + const { id } = permanentElementInDocument; + + for (const streamElement of fragment.querySelectorAll("turbo-stream")) { + const elementInStream = getPermanentElementById(streamElement.templateElement.content, id); + + if (elementInStream) { + permanentElementMap[id] = [permanentElementInDocument, elementInStream]; + } + } + } + + return permanentElementMap +} + +async function withAutofocusFromFragment(fragment, callback) { + const generatedID = `turbo-stream-autofocus-${uuid()}`; + const turboStreams = fragment.querySelectorAll("turbo-stream"); + const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams); + let willAutofocusId = null; + + if (elementWithAutofocus) { + if (elementWithAutofocus.id) { + willAutofocusId = elementWithAutofocus.id; + } else { + willAutofocusId = generatedID; + } + + elementWithAutofocus.id = willAutofocusId; + } + + callback(); + await nextRepaint(); + + const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body; + + if (hasNoActiveElement && willAutofocusId) { + const elementToAutofocus = document.getElementById(willAutofocusId); + + if (elementIsFocusable(elementToAutofocus)) { + elementToAutofocus.focus(); + } + if (elementToAutofocus && elementToAutofocus.id == generatedID) { + elementToAutofocus.removeAttribute("id"); + } + } +} + +async function withPreservedFocus(callback) { + const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement); + + const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id; + + if (restoreFocusTo) { + const elementToFocus = document.getElementById(restoreFocusTo); + + if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { + elementToFocus.focus(); + } + } +} + +function firstAutofocusableElementInStreams(nodeListOfStreamElements) { + for (const streamElement of nodeListOfStreamElements) { + const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content); + + if (elementWithAutofocus) return elementWithAutofocus + } + + return null +} + +class StreamObserver { + sources = new Set() + #started = false + + constructor(delegate) { + this.delegate = delegate; + } + + start() { + if (!this.#started) { + this.#started = true; + addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); + } + } + + stop() { + if (this.#started) { + this.#started = false; + removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); + } + } + + connectStreamSource(source) { + if (!this.streamSourceIsConnected(source)) { + this.sources.add(source); + source.addEventListener("message", this.receiveMessageEvent, false); + } + } + + disconnectStreamSource(source) { + if (this.streamSourceIsConnected(source)) { + this.sources.delete(source); + source.removeEventListener("message", this.receiveMessageEvent, false); + } + } + + streamSourceIsConnected(source) { + return this.sources.has(source) + } + + inspectFetchResponse = (event) => { + const response = fetchResponseFromEvent(event); + if (response && fetchResponseIsStream(response)) { + event.preventDefault(); + this.receiveMessageResponse(response); + } + } + + receiveMessageEvent = (event) => { + if (this.#started && typeof event.data == "string") { + this.receiveMessageHTML(event.data); + } + } + + async receiveMessageResponse(response) { + const html = await response.responseHTML; + if (html) { + this.receiveMessageHTML(html); + } + } + + receiveMessageHTML(html) { + this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)); + } +} + +function fetchResponseFromEvent(event) { + const fetchResponse = event.detail?.fetchResponse; + if (fetchResponse instanceof FetchResponse) { + return fetchResponse + } +} + +function fetchResponseIsStream(response) { + const contentType = response.contentType ?? ""; + return contentType.startsWith(StreamMessage.contentType) +} + +class ErrorRenderer extends Renderer { + static renderElement(currentElement, newElement) { + const { documentElement, body } = document; + + documentElement.replaceChild(newElement, body); + } + + async render() { + this.replaceHeadAndBody(); + this.activateScriptElements(); + } + + replaceHeadAndBody() { + const { documentElement, head } = document; + documentElement.replaceChild(this.newHead, head); + this.renderElement(this.currentElement, this.newElement); + } + + activateScriptElements() { + for (const replaceableElement of this.scriptElements) { + const parentNode = replaceableElement.parentNode; + if (parentNode) { + const element = activateScriptElement(replaceableElement); + parentNode.replaceChild(element, replaceableElement); + } + } + } + + get newHead() { + return this.newSnapshot.headSnapshot.element + } + + get scriptElements() { + return document.documentElement.querySelectorAll("script") + } +} + +// base IIFE to define idiomorph +var Idiomorph = (function () { + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + let EMPTY_SET = new Set(); + + // default configuration values, updatable by users now + let defaults = { + morphStyle: "outerHTML", + callbacks : { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, + + }, + head: { + style: 'merge', + shouldPreserve: function (elt) { + return elt.getAttribute("im-preserve") === "true"; + }, + shouldReAppend: function (elt) { + return elt.getAttribute("im-re-append") === "true"; + }, + shouldRemove: noOp, + afterHeadMorphed: noOp, + } + }; + + //============================================================================= + // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren + //============================================================================= + function morph(oldNode, newContent, config = {}) { + + if (oldNode instanceof Document) { + oldNode = oldNode.documentElement; + } + + if (typeof newContent === 'string') { + newContent = parseContent(newContent); + } + + let normalizedContent = normalizeContent(newContent); + + let ctx = createMorphContext(oldNode, normalizedContent, config); + + return morphNormalizedContent(oldNode, normalizedContent, ctx); + } + + function morphNormalizedContent(oldNode, normalizedNewContent, ctx) { + if (ctx.head.block) { + let oldHead = oldNode.querySelector('head'); + let newHead = normalizedNewContent.querySelector('head'); + if (oldHead && newHead) { + let promises = handleHeadElement(newHead, oldHead, ctx); + // when head promises resolve, call morph again, ignoring the head tag + Promise.all(promises).then(function () { + morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, { + head: { + block: false, + ignore: true + } + })); + }); + return; + } + } + + if (ctx.morphStyle === "innerHTML") { + + // innerHTML, so we are only updating the children + morphChildren(normalizedNewContent, oldNode, ctx); + return oldNode.children; + + } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) { + // otherwise find the best element match in the new content, morph that, and merge its siblings + // into either side of the best match + let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); + + // stash the siblings that will need to be inserted on either side of the best match + let previousSibling = bestMatch?.previousSibling; + let nextSibling = bestMatch?.nextSibling; + + // morph it + let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx); + + if (bestMatch) { + // if there was a best match, merge the siblings in too and return the + // whole bunch + return insertSiblings(previousSibling, morphedNode, nextSibling); + } else { + // otherwise nothing was added to the DOM + return [] + } + } else { + throw "Do not understand how to morph style " + ctx.morphStyle; + } + } + + + /** + * @param possibleActiveElement + * @param ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; + } + + /** + * @param oldNode root node to merge content into + * @param newContent new content to merge + * @param ctx the merge context + * @returns {Element} the element that ended up in the DOM + */ + function morphOldNodeTo(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + + oldNode.remove(); + ctx.callbacks.afterNodeRemoved(oldNode); + return null; + } else if (!isSoftMatch(oldNode, newContent)) { + if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode; + if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode; + + oldNode.parentElement.replaceChild(newContent, oldNode); + ctx.callbacks.afterNodeAdded(newContent); + ctx.callbacks.afterNodeRemoved(oldNode); + return newContent; + } else { + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode; + + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { + handleHeadElement(newContent, oldNode, ctx); + } else { + syncNodeFrom(newContent, oldNode, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + morphChildren(newContent, oldNode, ctx); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + } + + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm is, for each node in the new content: + * + * - if we have reached the end of the old parent, append the new content + * - if the new content has an id set match with the current insertion point, morph + * - search for an id set match + * - if id set match found, morph + * - otherwise search for a "soft" match + * - if a soft match is found, morph + * - otherwise, prepend the new node before the current insertion point + * + * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved + * with the current node. See findIdSetMatch() and findSoftMatch() for details. + * + * @param {Element} newParent the parent element of the new content + * @param {Element } oldParent the old content that we are merging the new content into + * @param ctx the merge context + */ + function morphChildren(newParent, oldParent, ctx) { + + let nextNewChild = newParent.firstChild; + let insertionPoint = oldParent.firstChild; + let newChild; + + // run through all the new content + while (nextNewChild) { + + newChild = nextNewChild; + nextNewChild = newChild.nextSibling; + + // if we are at the end of the exiting parent's children, just append + if (insertionPoint == null) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + + oldParent.appendChild(newChild); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // if the current node has an id set match then morph + if (isIdSetMatch(newChild, insertionPoint, ctx)) { + morphOldNodeTo(insertionPoint, newChild, ctx); + insertionPoint = insertionPoint.nextSibling; + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // otherwise search forward in the existing old children for an id set match + let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx); + + // if we found a potential match, remove the nodes until that point and morph + if (idSetMatch) { + insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx); + morphOldNodeTo(idSetMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // no id set match found, so scan forward for a soft match for the current node + let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx); + + // if we found a soft match for the current node, morph + if (softMatch) { + insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx); + morphOldNodeTo(softMatch, newChild, ctx); + removeIdsFromConsideration(ctx, newChild); + continue; + } + + // abandon all hope of morphing, just insert the new child before the insertion point + // and move on + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return; + + oldParent.insertBefore(newChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newChild); + removeIdsFromConsideration(ctx, newChild); + } + + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint !== null) { + + let tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(tempNode, ctx); + } + } + + //============================================================================= + // Attribute Syncing Code + //============================================================================= + + /** + * @param attr {String} the attribute to be mutated + * @param to {Element} the element that is going to be updated + * @param updateType {("update"|"remove")} + * @param ctx the merge context + * @returns {boolean} true if the attribute should be ignored, false otherwise + */ + function ignoreAttribute(attr, to, updateType, ctx) { + if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){ + return true; + } + return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false; + } + + /** + * syncs a given node with another node, copying over all attributes and + * inner element state from the 'from' node to the 'to' node + * + * @param {Element} from the element to copy attributes & state from + * @param {Element} to the element to copy attributes & state to + * @param ctx the merge context + */ + function syncNodeFrom(from, to, ctx) { + let type = from.nodeType; + + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const fromAttributes = from.attributes; + const toAttributes = to.attributes; + for (const fromAttribute of fromAttributes) { + if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) { + continue; + } + if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) { + to.setAttribute(fromAttribute.name, fromAttribute.value); + } + } + // iterate backwards to avoid skipping over items when a delete occurs + for (let i = toAttributes.length - 1; 0 <= i; i--) { + const toAttribute = toAttributes[i]; + if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) { + continue; + } + if (!from.hasAttribute(toAttribute.name)) { + to.removeAttribute(toAttribute.name); + } + } + } + + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (to.nodeValue !== from.nodeValue) { + to.nodeValue = from.nodeValue; + } + } + + if (!ignoreValueOfActiveElement(to, ctx)) { + // sync input values + syncInputValue(from, to, ctx); + } + } + + /** + * @param from {Element} element to sync the value from + * @param to {Element} element to sync the value to + * @param attributeName {String} the attribute name + * @param ctx the merge context + */ + function syncBooleanAttribute(from, to, attributeName, ctx) { + if (from[attributeName] !== to[attributeName]) { + let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx); + if (!ignoreUpdate) { + to[attributeName] = from[attributeName]; + } + if (from[attributeName]) { + if (!ignoreUpdate) { + to.setAttribute(attributeName, from[attributeName]); + } + } else { + if (!ignoreAttribute(attributeName, to, 'remove', ctx)) { + to.removeAttribute(attributeName); + } + } + } + } + + /** + * NB: many bothans died to bring us information: + * + * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + * + * @param from {Element} the element to sync the input value from + * @param to {Element} the element to sync the input value to + * @param ctx the merge context + */ + function syncInputValue(from, to, ctx) { + if (from instanceof HTMLInputElement && + to instanceof HTMLInputElement && + from.type !== 'file') { + + let fromValue = from.value; + let toValue = to.value; + + // sync boolean attributes + syncBooleanAttribute(from, to, 'checked', ctx); + syncBooleanAttribute(from, to, 'disabled', ctx); + + if (!from.hasAttribute('value')) { + if (!ignoreAttribute('value', to, 'remove', ctx)) { + to.value = ''; + to.removeAttribute('value'); + } + } else if (fromValue !== toValue) { + if (!ignoreAttribute('value', to, 'update', ctx)) { + to.setAttribute('value', fromValue); + to.value = fromValue; + } + } + } else if (from instanceof HTMLOptionElement) { + syncBooleanAttribute(from, to, 'selected', ctx); + } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) { + let fromValue = from.value; + let toValue = to.value; + if (ignoreAttribute('value', to, 'update', ctx)) { + return; + } + if (fromValue !== toValue) { + to.value = fromValue; + } + if (to.firstChild && to.firstChild.nodeValue !== fromValue) { + to.firstChild.nodeValue = fromValue; + } + } + } + + //============================================================================= + // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + //============================================================================= + function handleHeadElement(newHeadTag, currentHead, ctx) { + + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + + let headMergeStyle = ctx.head.style; + + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHeadTag.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + + // for each elt in the current head + for (const currentHeadElt of currentHead.children) { + + // If the current head element is in the map + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (headMergeStyle === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + + // Push the remaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); + + let promises = []; + for (const newNode of nodesToAppend) { + let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild; + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if (newElt.href || newElt.src) { + let resolve = null; + let promise = new Promise(function (_resolve) { + resolve = _resolve; + }); + newElt.addEventListener('load', function () { + resolve(); + }); + promises.push(promise); + } + currentHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + currentHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + + ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed}); + return promises; + } + + function noOp() { + } + + /* + Deep merges the config object and the Idiomoroph.defaults object to + produce a final configuration object + */ + function mergeDefaults(config) { + let finalConfig = {}; + // copy top level stuff into final config + Object.assign(finalConfig, defaults); + Object.assign(finalConfig, config); + + // copy callbacks into final config (do this to deep merge the callbacks) + finalConfig.callbacks = {}; + Object.assign(finalConfig.callbacks, defaults.callbacks); + Object.assign(finalConfig.callbacks, config.callbacks); + + // copy head config into final config (do this to deep merge the head) + finalConfig.head = {}; + Object.assign(finalConfig.head, defaults.head); + Object.assign(finalConfig.head, config.head); + return finalConfig; + } + + function createMorphContext(oldNode, newContent, config) { + config = mergeDefaults(config); + return { + target: oldNode, + newContent: newContent, + config: config, + morphStyle: config.morphStyle, + ignoreActive: config.ignoreActive, + ignoreActiveValue: config.ignoreActiveValue, + idMap: createIdMap(oldNode, newContent), + deadIds: new Set(), + callbacks: config.callbacks, + head: config.head + } + } + + function isIdSetMatch(node1, node2, ctx) { + if (node1 == null || node2 == null) { + return false; + } + if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) { + if (node1.id !== "" && node1.id === node2.id) { + return true; + } else { + return getIdIntersectionCount(ctx, node1, node2) > 0; + } + } + return false; + } + + function isSoftMatch(node1, node2) { + if (node1 == null || node2 == null) { + return false; + } + return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName + } + + function removeNodesBetween(startInclusive, endExclusive, ctx) { + while (startInclusive !== endExclusive) { + let tempNode = startInclusive; + startInclusive = startInclusive.nextSibling; + removeNode(tempNode, ctx); + } + removeIdsFromConsideration(ctx, endExclusive); + return endExclusive.nextSibling; + } + + //============================================================================= + // Scans forward from the insertionPoint in the old parent looking for a potential id match + // for the newChild. We stop if we find a potential id match for the new child OR + // if the number of potential id matches we are discarding is greater than the + // potential id matches for the new child + //============================================================================= + function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) { + + // max id matches we are willing to discard in our search + let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent); + + let potentialMatch = null; + + // only search forward if there is a possibility of an id match + if (newChildPotentialIdCount > 0) { + let potentialMatch = insertionPoint; + // if there is a possibility of an id match, scan forward + // keep track of the potential id match count we are discarding (the + // newChildPotentialIdCount must be greater than this to make it likely + // worth it) + let otherMatchCount = 0; + while (potentialMatch != null) { + + // If we have an id match, return the current potential match + if (isIdSetMatch(newChild, potentialMatch, ctx)) { + return potentialMatch; + } + + // computer the other potential matches of this new content + otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent); + if (otherMatchCount > newChildPotentialIdCount) { + // if we have more potential id matches in _other_ content, we + // do not have a good candidate for an id match, so return null + return null; + } + + // advanced to the next old content child + potentialMatch = potentialMatch.nextSibling; + } + } + return potentialMatch; + } + + //============================================================================= + // Scans forward from the insertionPoint in the old parent looking for a potential soft match + // for the newChild. We stop if we find a potential soft match for the new child OR + // if we find a potential id match in the old parents children OR if we find two + // potential soft matches for the next two pieces of new content + //============================================================================= + function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) { + + let potentialSoftMatch = insertionPoint; + let nextSibling = newChild.nextSibling; + let siblingSoftMatchCount = 0; + + while (potentialSoftMatch != null) { + + if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) { + // the current potential soft match has a potential id set match with the remaining new + // content so bail out of looking + return null; + } + + // if we have a soft match with the current node, return it + if (isSoftMatch(newChild, potentialSoftMatch)) { + return potentialSoftMatch; + } + + if (isSoftMatch(nextSibling, potentialSoftMatch)) { + // the next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + + // If there are two future soft matches, bail to allow the siblings to soft match + // so that we don't consume future soft matches for the sake of the current node + if (siblingSoftMatchCount >= 2) { + return null; + } + } + + // advanced to the next old content child + potentialSoftMatch = potentialSoftMatch.nextSibling; + } + + return potentialSoftMatch; + } + + function parseContent(newContent) { + let parser = new DOMParser(); + + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); + + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + content.generatedByIdiomorph = true; + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + htmlElement.generatedByIdiomorph = true; + return htmlElement; + } else { + return null; + } + } + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString("", "text/html"); + let content = responseDoc.body.querySelector('template').content; + content.generatedByIdiomorph = true; + return content + } + } + + function normalizeContent(newContent) { + if (newContent == null) { + // noinspection UnnecessaryLocalVariableJS + const dummyParent = document.createElement('div'); + return dummyParent; + } else if (newContent.generatedByIdiomorph) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return newContent; + } else if (newContent instanceof Node) { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement('div'); + dummyParent.append(newContent); + return dummyParent; + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement('div'); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } + } + + function insertSiblings(previousSibling, morphedNode, nextSibling) { + let stack = []; + let added = []; + while (previousSibling != null) { + stack.push(previousSibling); + previousSibling = previousSibling.previousSibling; + } + while (stack.length > 0) { + let node = stack.pop(); + added.push(node); // push added preceding siblings on in order and insert + morphedNode.parentElement.insertBefore(node, morphedNode); + } + added.push(morphedNode); + while (nextSibling != null) { + stack.push(nextSibling); + added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add + nextSibling = nextSibling.nextSibling; + } + while (stack.length > 0) { + morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling); + } + return added; + } + + function findBestNodeMatch(newContent, oldNode, ctx) { + let currentElement; + currentElement = newContent.firstChild; + let bestElement = currentElement; + let score = 0; + while (currentElement) { + let newScore = scoreElement(currentElement, oldNode, ctx); + if (newScore > score) { + bestElement = currentElement; + score = newScore; + } + currentElement = currentElement.nextSibling; + } + return bestElement; + } + + function scoreElement(node1, node2, ctx) { + if (isSoftMatch(node1, node2)) { + return .5 + getIdIntersectionCount(ctx, node1, node2); + } + return 0; + } + + function removeNode(tempNode, ctx) { + removeIdsFromConsideration(ctx, tempNode); + if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return; + + tempNode.remove(); + ctx.callbacks.afterNodeRemoved(tempNode); + } + + //============================================================================= + // ID Set Functions + //============================================================================= + + function isIdInConsideration(ctx, id) { + return !ctx.deadIds.has(id); + } + + function idIsWithinNode(ctx, id, targetNode) { + let idSet = ctx.idMap.get(targetNode) || EMPTY_SET; + return idSet.has(id); + } + + function removeIdsFromConsideration(ctx, node) { + let idSet = ctx.idMap.get(node) || EMPTY_SET; + for (const id of idSet) { + ctx.deadIds.add(id); + } + } + + function getIdIntersectionCount(ctx, node1, node2) { + let sourceSet = ctx.idMap.get(node1) || EMPTY_SET; + let matchCount = 0; + for (const id of sourceSet) { + // a potential match is an id in the source and potentialIdsSet, but + // that has not already been merged into the DOM + if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) { + ++matchCount; + } + } + return matchCount; + } + + /** + * A bottom up algorithm that finds all elements with ids inside of the node + * argument and populates id sets for those nodes and all their parents, generating + * a set of ids contained within all nodes for the entire hierarchy in the DOM + * + * @param node {Element} + * @param {Map>} idMap + */ + function populateIdMapForNode(node, idMap) { + let nodeParent = node.parentElement; + // find all elements with an id property + let idElements = node.querySelectorAll('[id]'); + for (const elt of idElements) { + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current !== nodeParent && current != null) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(elt.id); + current = current.parentElement; + } + } + } + + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {Map>} a map of nodes to id sets for the + */ + function createIdMap(oldContent, newContent) { + let idMap = new Map(); + populateIdMapForNode(oldContent, idMap); + populateIdMapForNode(newContent, idMap); + return idMap; + } + + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults + } + })(); + +class PageRenderer extends Renderer { + static renderElement(currentElement, newElement) { + if (document.body && newElement instanceof HTMLBodyElement) { + document.body.replaceWith(newElement); + } else { + document.documentElement.appendChild(newElement); + } + } + + get shouldRender() { + return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical + } + + get reloadReason() { + if (!this.newSnapshot.isVisitable) { + return { + reason: "turbo_visit_control_is_reload" + } + } + + if (!this.trackedElementsAreIdentical) { + return { + reason: "tracked_element_mismatch" + } + } + } + + async prepareToRender() { + this.#setLanguage(); + await this.mergeHead(); + } + + async render() { + if (this.willRender) { + await this.replaceBody(); + } + } + + finishRendering() { + super.finishRendering(); + if (!this.isPreview) { + this.focusFirstAutofocusableElement(); + } + } + + get currentHeadSnapshot() { + return this.currentSnapshot.headSnapshot + } + + get newHeadSnapshot() { + return this.newSnapshot.headSnapshot + } + + get newElement() { + return this.newSnapshot.element + } + + #setLanguage() { + const { documentElement } = this.currentSnapshot; + const { lang } = this.newSnapshot; + + if (lang) { + documentElement.setAttribute("lang", lang); + } else { + documentElement.removeAttribute("lang"); + } + } + + async mergeHead() { + const mergedHeadElements = this.mergeProvisionalElements(); + const newStylesheetElements = this.copyNewHeadStylesheetElements(); + this.copyNewHeadScriptElements(); + + await mergedHeadElements; + await newStylesheetElements; + + if (this.willRender) { + this.removeUnusedDynamicStylesheetElements(); + } + } + + async replaceBody() { + await this.preservingPermanentElements(async () => { + this.activateNewBody(); + await this.assignNewBody(); + }); + } + + get trackedElementsAreIdentical() { + return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature + } + + async copyNewHeadStylesheetElements() { + const loadingElements = []; + + for (const element of this.newHeadStylesheetElements) { + loadingElements.push(waitForLoad(element)); + + document.head.appendChild(element); + } + + await Promise.all(loadingElements); + } + + copyNewHeadScriptElements() { + for (const element of this.newHeadScriptElements) { + document.head.appendChild(activateScriptElement(element)); + } + } + + removeUnusedDynamicStylesheetElements() { + for (const element of this.unusedDynamicStylesheetElements) { + document.head.removeChild(element); + } + } + + async mergeProvisionalElements() { + const newHeadElements = [...this.newHeadProvisionalElements]; + + for (const element of this.currentHeadProvisionalElements) { + if (!this.isCurrentElementInElementList(element, newHeadElements)) { + document.head.removeChild(element); + } + } + + for (const element of newHeadElements) { + document.head.appendChild(element); + } + } + + isCurrentElementInElementList(element, elementList) { + for (const [index, newElement] of elementList.entries()) { + // if title element... + if (element.tagName == "TITLE") { + if (newElement.tagName != "TITLE") { + continue + } + if (element.innerHTML == newElement.innerHTML) { + elementList.splice(index, 1); + return true + } + } + + // if any other element... + if (newElement.isEqualNode(element)) { + elementList.splice(index, 1); + return true + } + } + + return false + } + + removeCurrentHeadProvisionalElements() { + for (const element of this.currentHeadProvisionalElements) { + document.head.removeChild(element); + } + } + + copyNewHeadProvisionalElements() { + for (const element of this.newHeadProvisionalElements) { + document.head.appendChild(element); + } + } + + activateNewBody() { + document.adoptNode(this.newElement); + this.activateNewBodyScriptElements(); + } + + activateNewBodyScriptElements() { + for (const inertScriptElement of this.newBodyScriptElements) { + const activatedScriptElement = activateScriptElement(inertScriptElement); + inertScriptElement.replaceWith(activatedScriptElement); + } + } + + async assignNewBody() { + await this.renderElement(this.currentElement, this.newElement); + } + + get unusedDynamicStylesheetElements() { + return this.oldHeadStylesheetElements.filter((element) => { + return element.getAttribute("data-turbo-track") === "dynamic" + }) + } + + get oldHeadStylesheetElements() { + return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot) + } + + get newHeadStylesheetElements() { + return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot) + } + + get newHeadScriptElements() { + return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot) + } + + get currentHeadProvisionalElements() { + return this.currentHeadSnapshot.provisionalElements + } + + get newHeadProvisionalElements() { + return this.newHeadSnapshot.provisionalElements + } + + get newBodyScriptElements() { + return this.newElement.querySelectorAll("script") + } +} + +class MorphRenderer extends PageRenderer { + async render() { + if (this.willRender) await this.#morphBody(); + } + + get renderMethod() { + return "morph" + } + + // Private + + async #morphBody() { + this.#morphElements(this.currentElement, this.newElement); + this.#reloadRemoteFrames(); + + dispatch("turbo:morph", { + detail: { + currentElement: this.currentElement, + newElement: this.newElement + } + }); + } + + #morphElements(currentElement, newElement, morphStyle = "outerHTML") { + this.isMorphingTurboFrame = this.#isFrameReloadedWithMorph(currentElement); + + Idiomorph.morph(currentElement, newElement, { + morphStyle: morphStyle, + callbacks: { + beforeNodeAdded: this.#shouldAddElement, + beforeNodeMorphed: this.#shouldMorphElement, + beforeAttributeUpdated: this.#shouldUpdateAttribute, + beforeNodeRemoved: this.#shouldRemoveElement, + afterNodeMorphed: this.#didMorphElement + } + }); + } + + #shouldAddElement = (node) => { + return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)) + } + + #shouldMorphElement = (oldNode, newNode) => { + if (oldNode instanceof HTMLElement) { + if (!oldNode.hasAttribute("data-turbo-permanent") && (this.isMorphingTurboFrame || !this.#isFrameReloadedWithMorph(oldNode))) { + const event = dispatch("turbo:before-morph-element", { + cancelable: true, + target: oldNode, + detail: { + newElement: newNode + } + }); + + return !event.defaultPrevented + } else { + return false + } + } + } + + #shouldUpdateAttribute = (attributeName, target, mutationType) => { + const event = dispatch("turbo:before-morph-attribute", { cancelable: true, target, detail: { attributeName, mutationType } }); + + return !event.defaultPrevented + } + + #didMorphElement = (oldNode, newNode) => { + if (newNode instanceof HTMLElement) { + dispatch("turbo:morph-element", { + target: oldNode, + detail: { + newElement: newNode + } + }); + } + } + + #shouldRemoveElement = (node) => { + return this.#shouldMorphElement(node) + } + + #reloadRemoteFrames() { + this.#remoteFrames().forEach((frame) => { + if (this.#isFrameReloadedWithMorph(frame)) { + this.#renderFrameWithMorph(frame); + frame.reload(); + } + }); + } + + #renderFrameWithMorph(frame) { + frame.addEventListener("turbo:before-frame-render", (event) => { + event.detail.render = this.#morphFrameUpdate; + }, { once: true }); + } + + #morphFrameUpdate = (currentElement, newElement) => { + dispatch("turbo:before-frame-morph", { + target: currentElement, + detail: { currentElement, newElement } + }); + this.#morphElements(currentElement, newElement.children, "innerHTML"); + } + + #isFrameReloadedWithMorph(element) { + return element.src && element.refresh === "morph" + } + + #remoteFrames() { + return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => { + return !frame.closest('[data-turbo-permanent]') + }) + } +} + +class SnapshotCache { + keys = [] + snapshots = {} + + constructor(size) { + this.size = size; + } + + has(location) { + return toCacheKey(location) in this.snapshots + } + + get(location) { + if (this.has(location)) { + const snapshot = this.read(location); + this.touch(location); + return snapshot + } + } + + put(location, snapshot) { + this.write(location, snapshot); + this.touch(location); + return snapshot + } + + clear() { + this.snapshots = {}; + } + + // Private + + read(location) { + return this.snapshots[toCacheKey(location)] + } + + write(location, snapshot) { + this.snapshots[toCacheKey(location)] = snapshot; + } + + touch(location) { + const key = toCacheKey(location); + const index = this.keys.indexOf(key); + if (index > -1) this.keys.splice(index, 1); + this.keys.unshift(key); + this.trim(); + } + + trim() { + for (const key of this.keys.splice(this.size)) { + delete this.snapshots[key]; + } + } +} + +class PageView extends View { + snapshotCache = new SnapshotCache(10) + lastRenderedLocation = new URL(location.href) + forceReloaded = false + + shouldTransitionTo(newSnapshot) { + return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions + } + + renderPage(snapshot, isPreview = false, willRender = true, visit) { + const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage; + const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer; + + const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender); + + if (!renderer.shouldRender) { + this.forceReloaded = true; + } else { + visit?.changeHistory(); + } + + return this.render(renderer) + } + + renderError(snapshot, visit) { + visit?.changeHistory(); + const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false); + return this.render(renderer) + } + + clearSnapshotCache() { + this.snapshotCache.clear(); + } + + async cacheSnapshot(snapshot = this.snapshot) { + if (snapshot.isCacheable) { + this.delegate.viewWillCacheSnapshot(); + const { lastRenderedLocation: location } = this; + await nextEventLoopTick(); + const cachedSnapshot = snapshot.clone(); + this.snapshotCache.put(location, cachedSnapshot); + return cachedSnapshot + } + } + + getCachedSnapshotForLocation(location) { + return this.snapshotCache.get(location) + } + + isPageRefresh(visit) { + return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace") + } + + shouldPreserveScrollPosition(visit) { + return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition + } + + get snapshot() { + return PageSnapshot.fromElement(this.element) + } +} + +class Preloader { + selector = "a[data-turbo-preload]" + + constructor(delegate, snapshotCache) { + this.delegate = delegate; + this.snapshotCache = snapshotCache; + } + + start() { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", this.#preloadAll); + } else { + this.preloadOnLoadLinksForView(document.body); + } + } + + stop() { + document.removeEventListener("DOMContentLoaded", this.#preloadAll); + } + + preloadOnLoadLinksForView(element) { + for (const link of element.querySelectorAll(this.selector)) { + if (this.delegate.shouldPreloadLink(link)) { + this.preloadURL(link); + } + } + } + + async preloadURL(link) { + const location = new URL(link.href); + + if (this.snapshotCache.has(location)) { + return + } + + const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link); + await fetchRequest.perform(); + } + + // Fetch request delegate + + prepareRequest(fetchRequest) { + fetchRequest.headers["X-Sec-Purpose"] = "prefetch"; + } + + async requestSucceededWithResponse(fetchRequest, fetchResponse) { + try { + const responseHTML = await fetchResponse.responseHTML; + const snapshot = PageSnapshot.fromHTMLString(responseHTML); + + this.snapshotCache.put(fetchRequest.url, snapshot); + } catch (_) { + // If we cannot preload that is ok! + } + } + + requestStarted(fetchRequest) {} + + requestErrored(fetchRequest) {} + + requestFinished(fetchRequest) {} + + requestPreventedHandlingResponse(fetchRequest, fetchResponse) {} + + requestFailedWithResponse(fetchRequest, fetchResponse) {} + + #preloadAll = () => { + this.preloadOnLoadLinksForView(document.body); + } +} + +class Cache { + constructor(session) { + this.session = session; + } + + clear() { + this.session.clearCache(); + } + + resetCacheControl() { + this.#setCacheControl(""); + } + + exemptPageFromCache() { + this.#setCacheControl("no-cache"); + } + + exemptPageFromPreview() { + this.#setCacheControl("no-preview"); + } + + #setCacheControl(value) { + setMetaContent("turbo-cache-control", value); + } +} + +class Session { + navigator = new Navigator(this) + history = new History(this) + view = new PageView(this, document.documentElement) + adapter = new BrowserAdapter(this) + + pageObserver = new PageObserver(this) + cacheObserver = new CacheObserver() + linkPrefetchObserver = new LinkPrefetchObserver(this, document) + linkClickObserver = new LinkClickObserver(this, window) + formSubmitObserver = new FormSubmitObserver(this, document) + scrollObserver = new ScrollObserver(this) + streamObserver = new StreamObserver(this) + formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement) + frameRedirector = new FrameRedirector(this, document.documentElement) + streamMessageRenderer = new StreamMessageRenderer() + cache = new Cache(this) + + drive = true + enabled = true + progressBarDelay = 500 + started = false + formMode = "on" + #pageRefreshDebouncePeriod = 150 + + constructor(recentRequests) { + this.recentRequests = recentRequests; + this.preloader = new Preloader(this, this.view.snapshotCache); + this.debouncedRefresh = this.refresh; + this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod; + } + + start() { + if (!this.started) { + this.pageObserver.start(); + this.cacheObserver.start(); + this.linkPrefetchObserver.start(); + this.formLinkClickObserver.start(); + this.linkClickObserver.start(); + this.formSubmitObserver.start(); + this.scrollObserver.start(); + this.streamObserver.start(); + this.frameRedirector.start(); + this.history.start(); + this.preloader.start(); + this.started = true; + this.enabled = true; + } + } + + disable() { + this.enabled = false; + } + + stop() { + if (this.started) { + this.pageObserver.stop(); + this.cacheObserver.stop(); + this.linkPrefetchObserver.stop(); + this.formLinkClickObserver.stop(); + this.linkClickObserver.stop(); + this.formSubmitObserver.stop(); + this.scrollObserver.stop(); + this.streamObserver.stop(); + this.frameRedirector.stop(); + this.history.stop(); + this.preloader.stop(); + this.started = false; + } + } + + registerAdapter(adapter) { + this.adapter = adapter; + } + + visit(location, options = {}) { + const frameElement = options.frame ? document.getElementById(options.frame) : null; + + if (frameElement instanceof FrameElement) { + const action = options.action || getVisitAction(frameElement); + + frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action); + frameElement.src = location.toString(); + } else { + this.navigator.proposeVisit(expandURL(location), options); + } + } + + refresh(url, requestId) { + const isRecentRequest = requestId && this.recentRequests.has(requestId); + if (!isRecentRequest) { + this.visit(url, { action: "replace", shouldCacheSnapshot: false }); + } + } + + connectStreamSource(source) { + this.streamObserver.connectStreamSource(source); + } + + disconnectStreamSource(source) { + this.streamObserver.disconnectStreamSource(source); + } + + renderStreamMessage(message) { + this.streamMessageRenderer.render(StreamMessage.wrap(message)); + } + + clearCache() { + this.view.clearSnapshotCache(); + } + + setProgressBarDelay(delay) { + this.progressBarDelay = delay; + } + + setFormMode(mode) { + this.formMode = mode; + } + + get location() { + return this.history.location + } + + get restorationIdentifier() { + return this.history.restorationIdentifier + } + + get pageRefreshDebouncePeriod() { + return this.#pageRefreshDebouncePeriod + } + + set pageRefreshDebouncePeriod(value) { + this.refresh = debounce(this.debouncedRefresh.bind(this), value); + this.#pageRefreshDebouncePeriod = value; + } + + // Preloader delegate + + shouldPreloadLink(element) { + const isUnsafe = element.hasAttribute("data-turbo-method"); + const isStream = element.hasAttribute("data-turbo-stream"); + const frameTarget = element.getAttribute("data-turbo-frame"); + const frame = frameTarget == "_top" ? + null : + document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])"); + + if (isUnsafe || isStream || frame instanceof FrameElement) { + return false + } else { + const location = new URL(element.href); + + return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation) + } + } + + // History delegate + + historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) { + if (this.enabled) { + this.navigator.startVisit(location, restorationIdentifier, { + action: "restore", + historyChanged: true, + direction + }); + } else { + this.adapter.pageInvalidated({ + reason: "turbo_disabled" + }); + } + } + + // Scroll observer delegate + + scrollPositionChanged(position) { + this.history.updateRestorationData({ scrollPosition: position }); + } + + // Form click observer delegate + + willSubmitFormLinkToLocation(link, location) { + return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) + } + + submittedFormLinkToLocation() {} + + // Link hover observer delegate + + canPrefetchRequestToLocation(link, location) { + return ( + this.elementIsNavigatable(link) && + locationIsVisitable(location, this.snapshot.rootLocation) + ) + } + + // Link click observer delegate + + willFollowLinkToLocation(link, location, event) { + return ( + this.elementIsNavigatable(link) && + locationIsVisitable(location, this.snapshot.rootLocation) && + this.applicationAllowsFollowingLinkToLocation(link, location, event) + ) + } + + followedLinkToLocation(link, location) { + const action = this.getActionForLink(link); + const acceptsStreamResponse = link.hasAttribute("data-turbo-stream"); + + this.visit(location.href, { action, acceptsStreamResponse }); + } + + // Navigator delegate + + allowsVisitingLocationWithAction(location, action) { + return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location) + } + + visitProposedToLocation(location, options) { + extendURLWithDeprecatedProperties(location); + this.adapter.visitProposedToLocation(location, options); + } + + // Visit delegate + + visitStarted(visit) { + if (!visit.acceptsStreamResponse) { + markAsBusy(document.documentElement); + this.view.markVisitDirection(visit.direction); + } + extendURLWithDeprecatedProperties(visit.location); + if (!visit.silent) { + this.notifyApplicationAfterVisitingLocation(visit.location, visit.action); + } + } + + visitCompleted(visit) { + this.view.unmarkVisitDirection(); + clearBusyState(document.documentElement); + this.notifyApplicationAfterPageLoad(visit.getTimingMetrics()); + } + + locationWithActionIsSamePage(location, action) { + return this.navigator.locationWithActionIsSamePage(location, action) + } + + visitScrolledToSamePageLocation(oldURL, newURL) { + this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL); + } + + // Form submit observer delegate + + willSubmitForm(form, submitter) { + const action = getAction$1(form, submitter); + + return ( + this.submissionIsNavigatable(form, submitter) && + locationIsVisitable(expandURL(action), this.snapshot.rootLocation) + ) + } + + formSubmitted(form, submitter) { + this.navigator.submitForm(form, submitter); + } + + // Page observer delegate + + pageBecameInteractive() { + this.view.lastRenderedLocation = this.location; + this.notifyApplicationAfterPageLoad(); + } + + pageLoaded() { + this.history.assumeControlOfScrollRestoration(); + } + + pageWillUnload() { + this.history.relinquishControlOfScrollRestoration(); + } + + // Stream observer delegate + + receivedMessageFromStream(message) { + this.renderStreamMessage(message); + } + + // Page view delegate + + viewWillCacheSnapshot() { + if (!this.navigator.currentVisit?.silent) { + this.notifyApplicationBeforeCachingSnapshot(); + } + } + + allowsImmediateRender({ element }, options) { + const event = this.notifyApplicationBeforeRender(element, options); + const { + defaultPrevented, + detail: { render } + } = event; + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render; + } + + return !defaultPrevented + } + + viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { + this.view.lastRenderedLocation = this.history.location; + this.notifyApplicationAfterRender(renderMethod); + } + + preloadOnLoadLinksForView(element) { + this.preloader.preloadOnLoadLinksForView(element); + } + + viewInvalidated(reason) { + this.adapter.pageInvalidated(reason); + } + + // Frame element + + frameLoaded(frame) { + this.notifyApplicationAfterFrameLoad(frame); + } + + frameRendered(fetchResponse, frame) { + this.notifyApplicationAfterFrameRender(fetchResponse, frame); + } + + // Application events + + applicationAllowsFollowingLinkToLocation(link, location, ev) { + const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev); + return !event.defaultPrevented + } + + applicationAllowsVisitingLocation(location) { + const event = this.notifyApplicationBeforeVisitingLocation(location); + return !event.defaultPrevented + } + + notifyApplicationAfterClickingLinkToLocation(link, location, event) { + return dispatch("turbo:click", { + target: link, + detail: { url: location.href, originalEvent: event }, + cancelable: true + }) + } + + notifyApplicationBeforeVisitingLocation(location) { + return dispatch("turbo:before-visit", { + detail: { url: location.href }, + cancelable: true + }) + } + + notifyApplicationAfterVisitingLocation(location, action) { + return dispatch("turbo:visit", { detail: { url: location.href, action } }) + } + + notifyApplicationBeforeCachingSnapshot() { + return dispatch("turbo:before-cache") + } + + notifyApplicationBeforeRender(newBody, options) { + return dispatch("turbo:before-render", { + detail: { newBody, ...options }, + cancelable: true + }) + } + + notifyApplicationAfterRender(renderMethod) { + return dispatch("turbo:render", { detail: { renderMethod } }) + } + + notifyApplicationAfterPageLoad(timing = {}) { + return dispatch("turbo:load", { + detail: { url: this.location.href, timing } + }) + } + + notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { + dispatchEvent( + new HashChangeEvent("hashchange", { + oldURL: oldURL.toString(), + newURL: newURL.toString() + }) + ); + } + + notifyApplicationAfterFrameLoad(frame) { + return dispatch("turbo:frame-load", { target: frame }) + } + + notifyApplicationAfterFrameRender(fetchResponse, frame) { + return dispatch("turbo:frame-render", { + detail: { fetchResponse }, + target: frame, + cancelable: true + }) + } + + // Helpers + + submissionIsNavigatable(form, submitter) { + if (this.formMode == "off") { + return false + } else { + const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true; + + if (this.formMode == "optin") { + return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null + } else { + return submitterIsNavigatable && this.elementIsNavigatable(form) + } + } + } + + elementIsNavigatable(element) { + const container = findClosestRecursively(element, "[data-turbo]"); + const withinFrame = findClosestRecursively(element, "turbo-frame"); + + // Check if Drive is enabled on the session or we're within a Frame. + if (this.drive || withinFrame) { + // Element is navigatable by default, unless `data-turbo="false"`. + if (container) { + return container.getAttribute("data-turbo") != "false" + } else { + return true + } + } else { + // Element isn't navigatable by default, unless `data-turbo="true"`. + if (container) { + return container.getAttribute("data-turbo") == "true" + } else { + return false + } + } + } + + // Private + + getActionForLink(link) { + return getVisitAction(link) || "advance" + } + + get snapshot() { + return this.view.snapshot + } +} + +// Older versions of the Turbo Native adapters referenced the +// `Location#absoluteURL` property in their implementations of +// the `Adapter#visitProposedToLocation()` and `#visitStarted()` +// methods. The Location class has since been removed in favor +// of the DOM URL API, and accordingly all Adapter methods now +// receive URL objects. +// +// We alias #absoluteURL to #toString() here to avoid crashing +// older adapters which do not expect URL objects. We should +// consider removing this support at some point in the future. + +function extendURLWithDeprecatedProperties(url) { + Object.defineProperties(url, deprecatedLocationPropertyDescriptors); +} + +const deprecatedLocationPropertyDescriptors = { + absoluteURL: { + get() { + return this.toString() + } + } +}; + +const session = new Session(recentRequests); +const { cache, navigator: navigator$1 } = session; + +/** + * Starts the main session. + * This initialises any necessary observers such as those to monitor + * link interactions. + */ +function start() { + session.start(); +} + +/** + * Registers an adapter for the main session. + * + * @param adapter Adapter to register + */ +function registerAdapter(adapter) { + session.registerAdapter(adapter); +} + +/** + * Performs an application visit to the given location. + * + * @param location Location to visit (a URL or path) + * @param options Options to apply + * @param options.action Type of history navigation to apply ("restore", + * "replace" or "advance") + * @param options.historyChanged Specifies whether the browser history has + * already been changed for this visit or not + * @param options.referrer Specifies the referrer of this visit such that + * navigations to the same page will not result in a new history entry. + * @param options.snapshotHTML Cached snapshot to render + * @param options.response Response of the specified location + */ +function visit(location, options) { + session.visit(location, options); +} + +/** + * Connects a stream source to the main session. + * + * @param source Stream source to connect + */ +function connectStreamSource(source) { + session.connectStreamSource(source); +} + +/** + * Disconnects a stream source from the main session. + * + * @param source Stream source to disconnect + */ +function disconnectStreamSource(source) { + session.disconnectStreamSource(source); +} + +/** + * Renders a stream message to the main session by appending it to the + * current document. + * + * @param message Message to render + */ +function renderStreamMessage(message) { + session.renderStreamMessage(message); +} + +/** + * Removes all entries from the Turbo Drive page cache. + * Call this when state has changed on the server that may affect cached pages. + * + * @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()` + */ +function clearCache() { + console.warn( + "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`" + ); + session.clearCache(); +} + +/** + * Sets the delay after which the progress bar will appear during navigation. + * + * The progress bar appears after 500ms by default. + * + * Note that this method has no effect when used with the iOS or Android + * adapters. + * + * @param delay Time to delay in milliseconds + */ +function setProgressBarDelay(delay) { + session.setProgressBarDelay(delay); +} + +function setConfirmMethod(confirmMethod) { + FormSubmission.confirmMethod = confirmMethod; +} + +function setFormMode(mode) { + session.setFormMode(mode); +} + +var Turbo = /*#__PURE__*/Object.freeze({ + __proto__: null, + navigator: navigator$1, + session: session, + cache: cache, + PageRenderer: PageRenderer, + PageSnapshot: PageSnapshot, + FrameRenderer: FrameRenderer, + fetch: fetchWithTurboHeaders, + start: start, + registerAdapter: registerAdapter, + visit: visit, + connectStreamSource: connectStreamSource, + disconnectStreamSource: disconnectStreamSource, + renderStreamMessage: renderStreamMessage, + clearCache: clearCache, + setProgressBarDelay: setProgressBarDelay, + setConfirmMethod: setConfirmMethod, + setFormMode: setFormMode +}); + +class TurboFrameMissingError extends Error {} + +class FrameController { + fetchResponseLoaded = (_fetchResponse) => Promise.resolve() + #currentFetchRequest = null + #resolveVisitPromise = () => {} + #connected = false + #hasBeenLoaded = false + #ignoredAttributes = new Set() + action = null + + constructor(element) { + this.element = element; + this.view = new FrameView(this, this.element); + this.appearanceObserver = new AppearanceObserver(this, this.element); + this.formLinkClickObserver = new FormLinkClickObserver(this, this.element); + this.linkInterceptor = new LinkInterceptor(this, this.element); + this.restorationIdentifier = uuid(); + this.formSubmitObserver = new FormSubmitObserver(this, this.element); + } + + // Frame delegate + + connect() { + if (!this.#connected) { + this.#connected = true; + if (this.loadingStyle == FrameLoadingStyle.lazy) { + this.appearanceObserver.start(); + } else { + this.#loadSourceURL(); + } + this.formLinkClickObserver.start(); + this.linkInterceptor.start(); + this.formSubmitObserver.start(); + } + } + + disconnect() { + if (this.#connected) { + this.#connected = false; + this.appearanceObserver.stop(); + this.formLinkClickObserver.stop(); + this.linkInterceptor.stop(); + this.formSubmitObserver.stop(); + } + } + + disabledChanged() { + if (this.loadingStyle == FrameLoadingStyle.eager) { + this.#loadSourceURL(); + } + } + + sourceURLChanged() { + if (this.#isIgnoringChangesTo("src")) return + + if (this.element.isConnected) { + this.complete = false; + } + + if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { + this.#loadSourceURL(); + } + } + + sourceURLReloaded() { + const { src } = this.element; + this.#ignoringChangesToAttribute("complete", () => { + this.element.removeAttribute("complete"); + }); + this.element.src = null; + this.element.src = src; + return this.element.loaded + } + + completeChanged() { + if (this.#isIgnoringChangesTo("complete")) return + + this.#loadSourceURL(); + } + + loadingStyleChanged() { + if (this.loadingStyle == FrameLoadingStyle.lazy) { + this.appearanceObserver.start(); + } else { + this.appearanceObserver.stop(); + this.#loadSourceURL(); + } + } + + async #loadSourceURL() { + if (this.enabled && this.isActive && !this.complete && this.sourceURL) { + this.element.loaded = this.#visit(expandURL(this.sourceURL)); + this.appearanceObserver.stop(); + await this.element.loaded; + this.#hasBeenLoaded = true; + } + } + + async loadResponse(fetchResponse) { + if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) { + this.sourceURL = fetchResponse.response.url; + } + + try { + const html = await fetchResponse.responseHTML; + if (html) { + const document = parseHTMLDocument(html); + const pageSnapshot = PageSnapshot.fromDocument(document); + + if (pageSnapshot.isVisitable) { + await this.#loadFrameResponse(fetchResponse, document); + } else { + await this.#handleUnvisitableFrameResponse(fetchResponse); + } + } + } finally { + this.fetchResponseLoaded = () => Promise.resolve(); + } + } + + // Appearance observer delegate + + elementAppearedInViewport(element) { + this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)); + this.#loadSourceURL(); + } + + // Form link click observer delegate + + willSubmitFormLinkToLocation(link) { + return this.#shouldInterceptNavigation(link) + } + + submittedFormLinkToLocation(link, _location, form) { + const frame = this.#findFrameElement(link); + if (frame) form.setAttribute("data-turbo-frame", frame.id); + } + + // Link interceptor delegate + + shouldInterceptLinkClick(element, _location, _event) { + return this.#shouldInterceptNavigation(element) + } + + linkClickIntercepted(element, location) { + this.#navigateFrame(element, location); + } + + // Form submit observer delegate + + willSubmitForm(element, submitter) { + return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter) + } + + formSubmitted(element, submitter) { + if (this.formSubmission) { + this.formSubmission.stop(); + } + + this.formSubmission = new FormSubmission(this, element, submitter); + const { fetchRequest } = this.formSubmission; + this.prepareRequest(fetchRequest); + this.formSubmission.start(); + } + + // Fetch request delegate + + prepareRequest(request) { + request.headers["Turbo-Frame"] = this.id; + + if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { + request.acceptResponseType(StreamMessage.contentType); + } + } + + requestStarted(_request) { + markAsBusy(this.element); + } + + requestPreventedHandlingResponse(_request, _response) { + this.#resolveVisitPromise(); + } + + async requestSucceededWithResponse(request, response) { + await this.loadResponse(response); + this.#resolveVisitPromise(); + } + + async requestFailedWithResponse(request, response) { + await this.loadResponse(response); + this.#resolveVisitPromise(); + } + + requestErrored(request, error) { + console.error(error); + this.#resolveVisitPromise(); + } + + requestFinished(_request) { + clearBusyState(this.element); + } + + // Form submission delegate + + formSubmissionStarted({ formElement }) { + markAsBusy(formElement, this.#findFrameElement(formElement)); + } + + formSubmissionSucceededWithResponse(formSubmission, response) { + const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter); + + frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)); + frame.delegate.loadResponse(response); + + if (!formSubmission.isSafe) { + session.clearCache(); + } + } + + formSubmissionFailedWithResponse(formSubmission, fetchResponse) { + this.element.delegate.loadResponse(fetchResponse); + session.clearCache(); + } + + formSubmissionErrored(formSubmission, error) { + console.error(error); + } + + formSubmissionFinished({ formElement }) { + clearBusyState(formElement, this.#findFrameElement(formElement)); + } + + // View delegate + + allowsImmediateRender({ element: newFrame }, options) { + const event = dispatch("turbo:before-frame-render", { + target: this.element, + detail: { newFrame, ...options }, + cancelable: true + }); + const { + defaultPrevented, + detail: { render } + } = event; + + if (this.view.renderer && render) { + this.view.renderer.renderElement = render; + } + + return !defaultPrevented + } + + viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {} + + preloadOnLoadLinksForView(element) { + session.preloadOnLoadLinksForView(element); + } + + viewInvalidated() {} + + // Frame renderer delegate + + willRenderFrame(currentElement, _newElement) { + this.previousFrameElement = currentElement.cloneNode(true); + } + + visitCachedSnapshot = ({ element }) => { + const frame = element.querySelector("#" + this.element.id); + + if (frame && this.previousFrameElement) { + frame.replaceChildren(...this.previousFrameElement.children); + } + + delete this.previousFrameElement; + } + + // Private + + async #loadFrameResponse(fetchResponse, document) { + const newFrameElement = await this.extractForeignFrameElement(document.body); + + if (newFrameElement) { + const snapshot = new Snapshot(newFrameElement); + const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false); + if (this.view.renderPromise) await this.view.renderPromise; + this.changeHistory(); + + await this.view.render(renderer); + this.complete = true; + session.frameRendered(fetchResponse, this.element); + session.frameLoaded(this.element); + await this.fetchResponseLoaded(fetchResponse); + } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { + this.#handleFrameMissingFromResponse(fetchResponse); + } + } + + async #visit(url) { + const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element); + + this.#currentFetchRequest?.cancel(); + this.#currentFetchRequest = request; + + return new Promise((resolve) => { + this.#resolveVisitPromise = () => { + this.#resolveVisitPromise = () => {}; + this.#currentFetchRequest = null; + resolve(); + }; + request.perform(); + }) + } + + #navigateFrame(element, url, submitter) { + const frame = this.#findFrameElement(element, submitter); + + frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame)); + + this.#withCurrentNavigationElement(element, () => { + frame.src = url; + }); + } + + proposeVisitIfNavigatedWithAction(frame, action = null) { + this.action = action; + + if (this.action) { + const pageSnapshot = PageSnapshot.fromElement(frame).clone(); + const { visitCachedSnapshot } = frame.delegate; + + frame.delegate.fetchResponseLoaded = async (fetchResponse) => { + if (frame.src) { + const { statusCode, redirected } = fetchResponse; + const responseHTML = await fetchResponse.responseHTML; + const response = { statusCode, redirected, responseHTML }; + const options = { + response, + visitCachedSnapshot, + willRender: false, + updateHistory: false, + restorationIdentifier: this.restorationIdentifier, + snapshot: pageSnapshot + }; + + if (this.action) options.action = this.action; + + session.visit(frame.src, options); + } + }; + } + } + + changeHistory() { + if (this.action) { + const method = getHistoryMethodForAction(this.action); + session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier); + } + } + + async #handleUnvisitableFrameResponse(fetchResponse) { + console.warn( + `The response (${fetchResponse.statusCode}) from is performing a full page visit due to turbo-visit-control.` + ); + + await this.#visitResponse(fetchResponse.response); + } + + #willHandleFrameMissingFromResponse(fetchResponse) { + this.element.setAttribute("complete", ""); + + const response = fetchResponse.response; + const visit = async (url, options) => { + if (url instanceof Response) { + this.#visitResponse(url); + } else { + session.visit(url, options); + } + }; + + const event = dispatch("turbo:frame-missing", { + target: this.element, + detail: { response, visit }, + cancelable: true + }); + + return !event.defaultPrevented + } + + #handleFrameMissingFromResponse(fetchResponse) { + this.view.missing(); + this.#throwFrameMissingError(fetchResponse); + } + + #throwFrameMissingError(fetchResponse) { + const message = `The response (${fetchResponse.statusCode}) did not contain the expected and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`; + throw new TurboFrameMissingError(message) + } + + async #visitResponse(response) { + const wrapped = new FetchResponse(response); + const responseHTML = await wrapped.responseHTML; + const { location, redirected, statusCode } = wrapped; + + return session.visit(location, { response: { redirected, statusCode, responseHTML } }) + } + + #findFrameElement(element, submitter) { + const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); + return getFrameElementById(id) ?? this.element + } + + async extractForeignFrameElement(container) { + let element; + const id = CSS.escape(this.id); + + try { + element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL); + if (element) { + return element + } + + element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL); + if (element) { + await element.loaded; + return await this.extractForeignFrameElement(element) + } + } catch (error) { + console.error(error); + return new FrameElement() + } + + return null + } + + #formActionIsVisitable(form, submitter) { + const action = getAction$1(form, submitter); + + return locationIsVisitable(expandURL(action), this.rootLocation) + } + + #shouldInterceptNavigation(element, submitter) { + const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target"); + + if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) { + return false + } + + if (!this.enabled || id == "_top") { + return false + } + + if (id) { + const frameElement = getFrameElementById(id); + if (frameElement) { + return !frameElement.disabled + } + } + + if (!session.elementIsNavigatable(element)) { + return false + } + + if (submitter && !session.elementIsNavigatable(submitter)) { + return false + } + + return true + } + + // Computed properties + + get id() { + return this.element.id + } + + get enabled() { + return !this.element.disabled + } + + get sourceURL() { + if (this.element.src) { + return this.element.src + } + } + + set sourceURL(sourceURL) { + this.#ignoringChangesToAttribute("src", () => { + this.element.src = sourceURL ?? null; + }); + } + + get loadingStyle() { + return this.element.loading + } + + get isLoading() { + return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined + } + + get complete() { + return this.element.hasAttribute("complete") + } + + set complete(value) { + this.#ignoringChangesToAttribute("complete", () => { + if (value) { + this.element.setAttribute("complete", ""); + } else { + this.element.removeAttribute("complete"); + } + }); + } + + get isActive() { + return this.element.isActive && this.#connected + } + + get rootLocation() { + const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); + const root = meta?.content ?? "/"; + return expandURL(root) + } + + #isIgnoringChangesTo(attributeName) { + return this.#ignoredAttributes.has(attributeName) + } + + #ignoringChangesToAttribute(attributeName, callback) { + this.#ignoredAttributes.add(attributeName); + callback(); + this.#ignoredAttributes.delete(attributeName); + } + + #withCurrentNavigationElement(element, callback) { + this.currentNavigationElement = element; + callback(); + delete this.currentNavigationElement; + } +} + +function getFrameElementById(id) { + if (id != null) { + const element = document.getElementById(id); + if (element instanceof FrameElement) { + return element + } + } +} + +function activateElement(element, currentURL) { + if (element) { + const src = element.getAttribute("src"); + if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { + throw new Error(`Matching element has a source URL which references itself`) + } + if (element.ownerDocument !== document) { + element = document.importNode(element, true); + } + + if (element instanceof FrameElement) { + element.connectedCallback(); + element.disconnectedCallback(); + return element + } + } +} + +const StreamActions = { + after() { + this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); + }, + + append() { + this.removeDuplicateTargetChildren(); + this.targetElements.forEach((e) => e.append(this.templateContent)); + }, + + before() { + this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); + }, + + prepend() { + this.removeDuplicateTargetChildren(); + this.targetElements.forEach((e) => e.prepend(this.templateContent)); + }, + + remove() { + this.targetElements.forEach((e) => e.remove()); + }, + + replace() { + this.targetElements.forEach((e) => e.replaceWith(this.templateContent)); + }, + + update() { + this.targetElements.forEach((targetElement) => { + targetElement.innerHTML = ""; + targetElement.append(this.templateContent); + }); + }, + + refresh() { + session.refresh(this.baseURI, this.requestId); + } +}; + +//