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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
35 changes: 35 additions & 0 deletions docs/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Badge type="info" text="^0.4.0" />

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:
Expand Down
3 changes: 3 additions & 0 deletions lib/generators/reactionview/install_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/reactionview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions lib/reactionview/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
36 changes: 22 additions & 14 deletions lib/reactionview/template/handlers/erb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions lib/reactionview/template/handlers/herb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions lib/reactionview/template/local_template.rb
Original file line number Diff line number Diff line change
@@ -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
153 changes: 153 additions & 0 deletions test/template/handlers/external_template_mode_test.rb
Original file line number Diff line number Diff line change
@@ -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 = %(<p><h2>I am invalid</h2></p>)
VALID = %(<div id="x"><h1>Hi <%= @n %></h1></div>)

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
4 changes: 2 additions & 2 deletions test/template/handlers/herb_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = %(<p><h2>I am invalid</h2></p>)
Expand All @@ -389,7 +389,7 @@ def @view_context.ui_badge(count, **_options)
assert_equal "<p><h2>I am invalid</h2></p>", 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 = %(<p><h2>I am invalid</h2></p>)
Expand Down
Loading