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
)