From 0b98f55254cfe11b9b25f6326a7231c2ecba0c92 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 8 Aug 2026 04:26:51 +0200 Subject: [PATCH] Add `external_template_mode` configuration option --- README.md | 3 + docs/docs/installation.md | 35 ++++ .../reactionview/install_generator.rb | 3 + lib/reactionview.rb | 2 + lib/reactionview/config.rb | 15 ++ lib/reactionview/template/handlers/erb.rb | 36 +++-- lib/reactionview/template/handlers/herb.rb | 11 +- lib/reactionview/template/local_template.rb | 22 +++ .../handlers/external_template_mode_test.rb | 153 ++++++++++++++++++ test/template/handlers/herb_test.rb | 4 +- 10 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 lib/reactionview/template/local_template.rb create mode 100644 test/template/handlers/external_template_mode_test.rb diff --git a/README.md b/README.md index 2c80b10..5874974 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ ReActionView.configure do |config| # Validation mode (:raise, :overlay, or :none) — defaults to :raise in test, :overlay otherwise # config.validation_mode = :overlay + + # How to handle templates that come from gems (:fallback, :skip, or :compile), defaults to :fallback + # config.external_template_mode = :skip end ``` diff --git a/docs/docs/installation.md b/docs/docs/installation.md index e16182a..64235b4 100644 --- a/docs/docs/installation.md +++ b/docs/docs/installation.md @@ -93,6 +93,41 @@ With `Rails.root` at `/app` inside the container, a template at `/app/app/views/ Local template detection and the `herb-project-path` meta tag stay on `Rails.root`. The meta tag is compared against the path the `herb dev` server reports, so overriding it would make the dev tools treat the page as a different project and ignore it. ::: +#### Templates From Gems + +With `intercept_erb` enabled, ReActionView sees every `.html.erb` template Rails renders, including ones shipped inside gems. Those are not yours to fix, so they get their own handling: + +:::code-group +```ruby [config/initializers/reactionview.rb] +ReActionView.configure do |config| + config.external_template_mode = :fallback +end +``` +::: + +| Mode | Behavior | +| --- | --- | +| `:fallback` (default) | Compile with Herb. If that fails, log a warning and fall back to Rails' own ERB handler, so the template renders exactly as it would without ReActionView. | +| `:skip` | Never compile templates from gems. | +| `:compile` | No special treatment. Your `validation_mode` applies to them just as it does to your own templates, and nothing is rescued. | + +Templates are considered external when they live outside `Rails.root`, or inside `Bundler.bundle_path` for applications that vendor their gems with `bundle config set --local path vendor/bundle`. + +Anything other than these three values raises an `ArgumentError` when you set it, so a typo fails at boot rather than changing how your templates compile: + +```ruby +config.external_template_mode = :warm +# => ArgumentError: external_template_mode must be one of :fallback, :skip, or :compile, got :warm +``` + +::: info Why :fallback rather than :skip +Skipping silently means you never find out that a gem's templates cannot be compiled, which matters if you later want to rely on Herb processing them. `:fallback` keeps every environment behaving the same way and tells you which templates fell back. See [herb#1508](https://github.com/marcoroth/herb/issues/1508). +::: + +::: warning +In `:fallback` mode, external templates are always compiled with `validation_mode: :raise` regardless of your `validation_mode` setting, so a gem template can never put a validation overlay on your page over markup you cannot change. +::: + ## Verify Installation Create a test template to verify ReActionView is working: diff --git a/lib/generators/reactionview/install_generator.rb b/lib/generators/reactionview/install_generator.rb index 4ca6060..69e2530 100644 --- a/lib/generators/reactionview/install_generator.rb +++ b/lib/generators/reactionview/install_generator.rb @@ -29,6 +29,9 @@ def create_initializer # Validation mode (:raise, :overlay, or :none) — defaults to :raise in test, :overlay otherwise # config.validation_mode = :overlay + # How to handle templates that come from gems (:fallback, :skip, or :compile), defaults to :fallback + # config.external_template_mode = :skip + # Add custom transform visitors to process templates before compilation # config.transform_visitors = [ # Herb::Visitor::new diff --git a/lib/reactionview.rb b/lib/reactionview.rb index aeee3fe..471a6bc 100644 --- a/lib/reactionview.rb +++ b/lib/reactionview.rb @@ -17,6 +17,8 @@ # require_relative "reactionview/source_annotation_extractor" +require_relative "reactionview/template/local_template" + require_relative "reactionview/template/handlers/erb" require_relative "reactionview/template/handlers/herb" require_relative "reactionview/template/handlers/herb/herb" diff --git a/lib/reactionview/config.rb b/lib/reactionview/config.rb index 0b3a598..6a34377 100644 --- a/lib/reactionview/config.rb +++ b/lib/reactionview/config.rb @@ -6,6 +6,8 @@ class Config attr_accessor :debug_mode attr_accessor :transform_visitors + EXTERNAL_TEMPLATE_MODES = %i[fallback skip compile].freeze + attr_writer :dev_server_port attr_writer :project_path attr_writer :validation_mode @@ -14,10 +16,23 @@ def initialize @intercept_erb = false @debug_mode = nil @dev_server_port = nil + @external_template_mode = nil @transform_visitors = [] @project_path = nil end + def external_template_mode + @external_template_mode || :fallback + end + + def external_template_mode=(mode) + unless mode.nil? || EXTERNAL_TEMPLATE_MODES.include?(mode) + raise ArgumentError, "external_template_mode must be one of :fallback, :skip, or :compile, got #{mode.inspect}" + end + + @external_template_mode = mode + end + def project_path @project_path || Rails.root.to_s end diff --git a/lib/reactionview/template/handlers/erb.rb b/lib/reactionview/template/handlers/erb.rb index e5eb2d1..14c082b 100644 --- a/lib/reactionview/template/handlers/erb.rb +++ b/lib/reactionview/template/handlers/erb.rb @@ -4,33 +4,41 @@ module ReActionView class Template module Handlers class ERB < ActionView::Template::Handlers::ERB + include ReActionView::Template::LocalTemplate + autoload :Herb, "reactionview/template/handlers/herb/herb" def call(template, source) - if intercept_template?(template) - ::ReActionView::Template::Handlers::Herb.call(template, source) - else - super - end + return super unless intercept_template?(template) + + ::ReActionView::Template::Handlers::Herb.call(template, source) + rescue StandardError => e + raise unless fall_back_to_erb?(template) + + log_external_template_error(template, e) + + super end private def intercept_template?(template) - template.format == :html && ReActionView.config.intercept_erb && local_template?(template) - end + return false unless template.format == :html && ReActionView.config.intercept_erb - def local_template?(template) - return true unless template.respond_to?(:identifier) && template.identifier - return false if vendored_template?(template) + local_template?(template) || ReActionView.config.external_template_mode != :skip + end - template.identifier.start_with?(Rails.root.to_s) + def fall_back_to_erb?(template) + !local_template?(template) && ReActionView.config.external_template_mode == :fallback end - def vendored_template?(template) - return false unless defined?(Bundler) + def log_external_template_error(template, error) + return unless defined?(Rails.logger) && Rails.logger - template.identifier.start_with?(Bundler.bundle_path.to_s) + Rails.logger.warn( + "[ReActionView] #{template.identifier} could not be compiled by Herb, " \ + "falling back to ActionView::Template::Handlers::ERB: #{error.message.strip.lines.first}" + ) end end end diff --git a/lib/reactionview/template/handlers/herb.rb b/lib/reactionview/template/handlers/herb.rb index ef97747..06dfee2 100644 --- a/lib/reactionview/template/handlers/herb.rb +++ b/lib/reactionview/template/handlers/herb.rb @@ -4,6 +4,8 @@ module ReActionView class Template module Handlers class Herb < ActionView::Template::Handlers::ERB + include ReActionView::Template::LocalTemplate + autoload :Herb, "reactionview/template/handlers/herb/herb" class_attribute :erb_implementation, default: Handlers::Herb::Herb @@ -21,7 +23,7 @@ def call(template, source) config = { filename: template.identifier, project_path: Rails.root.to_s, - validation_mode: ReActionView.config.validation_mode, + validation_mode: validation_mode_for(template), content_for_head: reactionview_dev_tools_markup(template), visitors: visitors + ReActionView.config.transform_visitors, } @@ -37,10 +39,11 @@ def layout_template?(template) template.identifier.include?("/layouts/") end - def local_template?(template) - return true unless template.respond_to?(:identifier) && template.identifier + def validation_mode_for(template) + return ReActionView.config.validation_mode if local_template?(template) + return ReActionView.config.validation_mode if ReActionView.config.external_template_mode == :compile - template.identifier.start_with?(Rails.root.to_s) + :raise end def active_support_editor diff --git a/lib/reactionview/template/local_template.rb b/lib/reactionview/template/local_template.rb new file mode 100644 index 0000000..ace7df6 --- /dev/null +++ b/lib/reactionview/template/local_template.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ReActionView + class Template + module LocalTemplate + private + + def local_template?(template) + return true unless template.respond_to?(:identifier) && template.identifier + return false if vendored_template?(template) + + template.identifier.start_with?(Rails.root.to_s) + end + + def vendored_template?(template) + return false unless defined?(Bundler) + + template.identifier.start_with?(Bundler.bundle_path.to_s) + end + end + end +end diff --git a/test/template/handlers/external_template_mode_test.rb b/test/template/handlers/external_template_mode_test.rb new file mode 100644 index 0000000..70d3a12 --- /dev/null +++ b/test/template/handlers/external_template_mode_test.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require_relative "../../test_helper" + +class ReActionView::ExternalTemplateModeTest < Minitest::Spec + RAILS_ROOT = "/app" + EXTERNAL = "/gems/actionpack-8.1.2/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb" + LOCAL = "/app/app/views/users/show.html.erb" + + INVALID = %(

I am invalid

) + VALID = %(

Hi <%= @n %>

) + + before do + @previous_mode = ReActionView.config.external_template_mode + @previous_logger = Rails.logger + + ReActionView.config.debug_mode = false + ReActionView.config.intercept_erb = true + + @log = StringIO.new + Rails.logger = Logger.new(@log) + end + + after do + ReActionView.config.external_template_mode = @previous_mode + Rails.logger = @previous_logger + end + + def build(source, identifier) + ActionView::Template.new( + source, + identifier, + ReActionView::Template::Handlers::ERB, + virtual_path: "users/show", + format: :html, + locals: [] + ) + end + + def compile(source, identifier) + template = build(source, identifier) + + Rails.stub(:root, Pathname.new(RAILS_ROOT)) do + template.handler.call(template, source) + end + end + + test "defaults to :fallback" do + assert_equal :fallback, ReActionView::Config.new.external_template_mode + end + + test "returns the configured mode" do + config = ReActionView::Config.new + config.external_template_mode = :skip + + assert_equal :skip, config.external_template_mode + end + + test "nil resets to the default" do + config = ReActionView::Config.new + config.external_template_mode = :skip + config.external_template_mode = nil + + assert_equal :fallback, config.external_template_mode + end + + test "rejects an unknown mode" do + config = ReActionView::Config.new + + error = assert_raises(ArgumentError) do + config.external_template_mode = :warm + end + + assert_includes error.message, "must be one of :fallback, :skip, or :compile" + assert_includes error.message, ":warm" + end + + test ":fallback compiles external templates that Herb can handle" do + ReActionView.config.external_template_mode = :fallback + + compiled = compile(VALID, EXTERNAL) + + refute_equal ActionView::Template::Handlers::ERB.new.call(build(VALID, EXTERNAL), VALID), compiled + assert_empty @log.string + end + + test ":fallback falls back to ERB and logs when Herb cannot compile an external template" do + ReActionView.config.external_template_mode = :fallback + + compiled = compile(INVALID, EXTERNAL) + + assert_equal ActionView::Template::Handlers::ERB.new.call(build(INVALID, EXTERNAL), INVALID), compiled + assert_includes @log.string, "[ReActionView]" + assert_includes @log.string, EXTERNAL + assert_includes @log.string, "falling back to" + end + + test ":skip never compiles external templates" do + ReActionView.config.external_template_mode = :skip + + compiled = compile(VALID, EXTERNAL) + + assert_equal ActionView::Template::Handlers::ERB.new.call(build(VALID, EXTERNAL), VALID), compiled + assert_empty @log.string + end + + test ":compile lets external template failures raise" do + ReActionView.config.external_template_mode = :compile + ReActionView.config.validation_mode = :raise + + assert_raises(Herb::Engine::CompilationError) do + compile(INVALID, EXTERNAL) + end + ensure + ReActionView.config.validation_mode = nil + end + + test ":compile applies the configured validation mode to external templates" do + ReActionView.config.external_template_mode = :compile + ReActionView.config.validation_mode = :overlay + + compiled = compile(INVALID, EXTERNAL) + + assert_includes compiled, "data-herb-validation-error" + assert_empty @log.string + ensure + ReActionView.config.validation_mode = nil + end + + test "local template failures always raise, whatever the mode" do + ReActionView.config.external_template_mode = :fallback + ReActionView.config.validation_mode = :raise + + assert_raises(Herb::Engine::CompilationError) do + compile(INVALID, LOCAL) + end + + assert_empty @log.string + ensure + ReActionView.config.validation_mode = nil + end + + test "external templates never render a validation overlay in :fallback mode" do + ReActionView.config.external_template_mode = :fallback + ReActionView.config.validation_mode = :overlay + + compiled = compile(INVALID, EXTERNAL) + + refute_includes compiled, "data-herb-validation-error" + ensure + ReActionView.config.validation_mode = nil + end +end diff --git a/test/template/handlers/herb_test.rb b/test/template/handlers/herb_test.rb index 44cee8d..1da0252 100644 --- a/test/template/handlers/herb_test.rb +++ b/test/template/handlers/herb_test.rb @@ -366,7 +366,7 @@ def @view_context.ui_badge(count, **_options) assert_compiled_snapshot(template) end - test "does not process templates that are not local" do + test "renders templates that are not local with ActionView's ERB handler" do ReActionView.config.intercept_erb = true template = %(

I am invalid

) @@ -389,7 +389,7 @@ def @view_context.ui_badge(count, **_options) assert_equal "

I am invalid

", normalized_result end - test "does not process templates from gems vendored inside the application" do + test "renders templates from gems vendored inside the application with ActionView's ERB handler" do ReActionView.config.intercept_erb = true template = %(

I am invalid

)