Skip to content

Commit 171afb5

Browse files
BlakeWilliamsclaudiob
authored andcommitted
Add optional compatibility module for capture/form/turbo compatibility fixes (ViewComponent#1650)
This adds an optional module, `CaptureCompatibility` which is meant to monkey patch `ActionView::Base` so that forms, turbo frames, and content_for, and other common Rails code works as-expected.
1 parent 090fc31 commit 171afb5

File tree

12 files changed

+116
-9
lines changed

12 files changed

+116
-9
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,34 @@ jobs:
2929
include:
3030
- rails_version: "5.2.6"
3131
ruby_version: "2.7"
32+
mode: "capture_patch_enabled"
3233
- rails_version: "6.0.4.4"
3334
ruby_version: "2.7"
35+
mode: "capture_patch_enabled"
3436
- rails_version: "6.1.4.4"
3537
ruby_version: "2.7"
38+
mode: "capture_patch_enabled"
3639
- rails_version: "7.0.2.3"
3740
ruby_version: "3.0"
41+
mode: "capture_patch_enabled"
3842
- rails_version: "main"
3943
ruby_version: "3.1"
44+
mode: "capture_patch_enabled"
45+
- rails_version: "5.2.6"
46+
ruby_version: "2.7"
47+
mode: "capture_patch_disabled"
48+
- rails_version: "6.0.4.4"
49+
ruby_version: "2.7"
50+
mode: "capture_patch_disabled"
51+
- rails_version: "6.1.4.4"
52+
ruby_version: "2.7"
53+
mode: "capture_patch_disabled"
54+
- rails_version: "7.0.2.3"
55+
ruby_version: "3.0"
56+
mode: "capture_patch_disabled"
57+
- rails_version: "main"
58+
ruby_version: "3.1"
59+
mode: "capture_patch_disabled"
4060
steps:
4161
- uses: actions/checkout@master
4262
- name: Setup Ruby
@@ -56,6 +76,7 @@ jobs:
5676
RAISE_ON_WARNING: 1
5777
MEASURE_COVERAGE: true
5878
RAILS_VERSION: ${{ matrix.rails_version }}
79+
CAPTURE_PATCH_ENABLED: ${{ matrix.mode == 'capture_patch_enabled' && 'true' || 'false' }}
5980
- name: Upload coverage results
6081
uses: actions/upload-artifact@master
6182
if: always()

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ nav_order: 5
1010

1111
## main
1212

13+
* Add experimental `config.view_component.capture_compatibility_patch_enabled` option resolving rendering issues related to forms, capture, turbo frames, etc.
14+
15+
*Blake Williams*
16+
1317
* Add `#content?` method that indicates if content has been passed to component.
1418

1519
*Joel Hawksley*

lib/view_component.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module ViewComponent
77
extend ActiveSupport::Autoload
88

99
autoload :Base
10+
autoload :CaptureCompatibility
1011
autoload :Compiler
1112
autoload :CompileCache
1213
autoload :ComponentError
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# frozen_string_literal: true
2+
3+
module ViewComponent
4+
# CaptureCompatibility is a module that patches #capture to fix issues
5+
# related to ViewComponent and functionality that relies on `capture`
6+
# like forms, capture itself, turbo frames, etc.
7+
#
8+
# This underlying incompatibility with ViewComponent and capture is
9+
# that several features like forms keep a reference to the primary
10+
# `ActionView::Base` instance which has its own @output_buffer. When
11+
# `#capture` is called on the original `ActionView::Base` instance while
12+
# evaluating a block from a ViewComponent the @output_buffer is overridden
13+
# in the ActionView::Base instance, and *not* the component. This results
14+
# in a double render due to `#capture` implementation details.
15+
#
16+
# To resolve the issue, we override `#capture` so that we can delegate
17+
# the `capture` logic to the ViewComponent that created the block.
18+
module CaptureCompatibility
19+
def self.included(base)
20+
base.class_eval do
21+
alias_method :original_capture, :capture
22+
end
23+
24+
base.prepend(InstanceMethods)
25+
end
26+
27+
module InstanceMethods
28+
def capture(*args, &block)
29+
# Handle blocks that originate from C code and raise, such as `&:method`
30+
return original_capture(*args, &block) if block.source_location.nil?
31+
32+
block_context = block.binding.receiver
33+
34+
if block_context != self && block_context.class < ActionView::Base
35+
block_context.original_capture(*args, &block)
36+
else
37+
original_capture(*args, &block)
38+
end
39+
end
40+
end
41+
end
42+
end

lib/view_component/config.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ def defaults
2323
show_previews: Rails.env.development? || Rails.env.test?,
2424
preview_paths: default_preview_paths,
2525
test_controller: "ApplicationController",
26-
default_preview_layout: nil
26+
default_preview_layout: nil,
27+
capture_compatibility_patch_enabled: false
2728
})
2829
end
2930

@@ -137,6 +138,13 @@ def defaults
137138
# A custom default layout used for the previews index page and individual
138139
# previews.
139140
# Defaults to `nil`. If this is falsy, `"component_preview"` is used.
141+
#
142+
# @!attribute capture_compatibility_patch_enabled
143+
# @return [Boolean]
144+
# Enables the experimental capture compatibility patch that makes ViewComponent
145+
# compatible with forms, capture, and other built-ins.
146+
# previews.
147+
# Defaults to `false`.
140148

141149
def default_preview_paths
142150
return [] unless defined?(Rails.root) && Dir.exist?("#{Rails.root}/test/components/previews")

lib/view_component/engine.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ class Engine < Rails::Engine # :nodoc:
4747
end
4848
end
4949

50+
# :nocov:
51+
initializer "view_component.enable_capture_patch" do |app|
52+
ActiveSupport.on_load(:view_component) do
53+
ActionView::Base.include(ViewComponent::CaptureCompatibility) if app.config.view_component.capture_compatibility_patch_enabled
54+
end
55+
end
56+
# :nocov:
57+
5058
initializer "view_component.set_autoload_paths" do |app|
5159
options = app.config.view_component
5260

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<%= form_for Post.new, url: "/" do |form| %>
2+
<%= render "forms/label", form: form %>
3+
<% end %>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# frozen_string_literal: true
2+
3+
class FormPartialComponent < ViewComponent::Base
4+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<%= form.label :published do %>
2+
<%= form.check_box :published %>
3+
<% end %>

test/sandbox/config/environments/test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
config.view_component.render_monkey_patch_enabled = true
2626
config.view_component.show_previews_source = true
2727
config.view_component.test_controller = "IntegrationExamplesController"
28+
config.view_component.capture_compatibility_patch_enabled = ENV["CAPTURE_PATCH_ENABLED"] == "true"
2829

2930
# Tell Action Mailer not to deliver emails to the real world.
3031
# The :test delivery method accumulates sent emails in the

test/sandbox/test/action_view_compatibility_test.rb

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,39 @@
44

55
class ViewComponent::ActionViewCompatibilityTest < ViewComponent::TestCase
66
def test_renders_form_for_labels_with_block_correctly
7-
skip
8-
7+
skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true"
98
render_inline(FormForComponent.new)
109

1110
assert_selector("form > div > label > input")
1211
refute_selector("form > div > input")
1312
end
1413

1514
def test_renders_form_with_labels_with_block_correctly
16-
skip
17-
15+
skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true"
1816
render_inline(FormWithComponent.new)
1917

2018
assert_selector("form > div > label > input")
2119
refute_selector("form > div > input")
2220
end
2321

2422
def test_form_without_compatibility_does_not_raise
25-
skip
26-
23+
skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true"
2724
render_inline(IncompatibleFormComponent.new)
2825

2926
# Bad selector should be present, at least until fixed upstream or included by default
3027
refute_selector("form > div > input")
3128
end
3229

33-
def test_helper_with_content_tag
34-
skip
30+
def test_form_with_partial_render_works
31+
skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true"
32+
render_inline(FormPartialComponent.new)
3533

34+
# Bad selector should be present, at least until fixed upstream or included by default
35+
refute_selector("form > div > input")
36+
end
37+
38+
def test_helper_with_content_tag
39+
skip unless ENV["CAPTURE_PATCH_ENABLED"] == "true"
3640
render_inline(ContentTagComponent.new)
3741
assert_selector("div > p")
3842
end

test/sandbox/test/config_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,13 @@ def test_all_methods_are_documented
3737
assert configuration_methods_to_document.map(&:docstring).all?(&:present?),
3838
"Configuration options are missing docstrings."
3939
end
40+
41+
def test_compatibility_module_included
42+
if ENV["CAPTURE_PATCH_ENABLED"] == "true"
43+
assert ActionView::Base < ViewComponent::CaptureCompatibility
44+
else
45+
refute ActionView::Base < ViewComponent::CaptureCompatibility
46+
end
47+
end
4048
end
4149
end

0 commit comments

Comments
 (0)