Skip to content

Commit ac27d67

Browse files
committed
Prevent stack overflow in ActionText PlaintextConversion
In our app, we encountered edge cases where the ActionText plaintext conversion caused stack overflows. To address this, traverse a copy of the tree iteratively bottom-up, converting each node's content to plaintext using the already-converted children content. Finally, extract the result from the root node. This approach avoids recursion preventing stack overflows.
1 parent 730367e commit ac27d67

File tree

2 files changed

+68
-24
lines changed

2 files changed

+68
-24
lines changed

actiontext/lib/action_text/plain_text_conversion.rb

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,69 +7,71 @@ module PlainTextConversion
77
extend self
88

99
def node_to_plain_text(node)
10-
remove_trailing_newlines(plain_text_for_node(node))
10+
remove_trailing_newlines(node_to_plain_text_content_tree(node).content)
1111
end
1212

1313
private
14-
def plain_text_for_node(node, index = 0)
14+
def node_to_plain_text_content_tree(node)
15+
BottomUpReplacer.replace_content(node.dup) { |n| plain_text_for_node(n) }
16+
end
17+
18+
def plain_text_for_node(node)
1519
if respond_to?(plain_text_method_for_node(node), true)
16-
send(plain_text_method_for_node(node), node, index)
20+
send(plain_text_method_for_node(node), node)
1721
else
1822
plain_text_for_node_children(node)
1923
end
2024
end
2125

2226
def plain_text_for_node_children(node)
23-
texts = []
24-
node.children.each_with_index do |child, index|
25-
next if skippable?(child)
27+
node.children.map(&:content).join
28+
end
2629

27-
texts << plain_text_for_node(child, index)
28-
end
29-
texts.join
30+
def plain_text_method_for_node(node)
31+
:"plain_text_for_#{node.name}_node"
3032
end
3133

32-
def skippable?(node)
33-
node.name == "script" || node.name == "style"
34+
def plain_text_for_unsupported_node(node)
35+
""
3436
end
3537

36-
def plain_text_method_for_node(node)
37-
:"plain_text_for_#{node.name}_node"
38+
%i[ script style].each do |element|
39+
alias_method :"plain_text_for_#{element}_node", :plain_text_for_unsupported_node
3840
end
3941

40-
def plain_text_for_block(node, index = 0)
42+
def plain_text_for_block(node)
4143
"#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n"
4244
end
4345

4446
%i[ h1 p ].each do |element|
4547
alias_method :"plain_text_for_#{element}_node", :plain_text_for_block
4648
end
4749

48-
def plain_text_for_list(node, index)
50+
def plain_text_for_list(node)
4951
"#{break_if_nested_list(node, plain_text_for_block(node))}"
5052
end
5153

5254
%i[ ul ol ].each do |element|
5355
alias_method :"plain_text_for_#{element}_node", :plain_text_for_list
5456
end
5557

56-
def plain_text_for_br_node(node, index)
58+
def plain_text_for_br_node(node)
5759
"\n"
5860
end
5961

60-
def plain_text_for_text_node(node, index)
62+
def plain_text_for_text_node(node)
6163
remove_trailing_newlines(node.text)
6264
end
6365

64-
def plain_text_for_div_node(node, index)
66+
def plain_text_for_div_node(node)
6567
"#{remove_trailing_newlines(plain_text_for_node_children(node))}\n"
6668
end
6769

68-
def plain_text_for_figcaption_node(node, index)
70+
def plain_text_for_figcaption_node(node)
6971
"[#{remove_trailing_newlines(plain_text_for_node_children(node))}]"
7072
end
7173

72-
def plain_text_for_blockquote_node(node, index)
74+
def plain_text_for_blockquote_node(node)
7375
text = plain_text_for_block(node)
7476
return "“”" if text.blank?
7577

@@ -79,8 +81,8 @@ def plain_text_for_blockquote_node(node, index)
7981
text
8082
end
8183

82-
def plain_text_for_li_node(node, index)
83-
bullet = bullet_for_li_node(node, index)
84+
def plain_text_for_li_node(node)
85+
bullet = bullet_for_li_node(node)
8486
text = remove_trailing_newlines(plain_text_for_node_children(node))
8587
indentation = indentation_for_li_node(node)
8688

@@ -91,8 +93,9 @@ def remove_trailing_newlines(text)
9193
text.chomp("")
9294
end
9395

94-
def bullet_for_li_node(node, index)
96+
def bullet_for_li_node(node)
9597
if list_node_name_for_li_node(node) == "ol"
98+
index = node.parent.children.index(node)
9699
"#{index + 1}."
97100
else
98101
"•"
@@ -121,5 +124,38 @@ def break_if_nested_list(node, text)
121124
text
122125
end
123126
end
127+
128+
class BottomUpReplacer
129+
def self.replace_content(node, &block)
130+
new(node).replace_content(&block)
131+
end
132+
133+
def initialize(node)
134+
@node = node
135+
end
136+
137+
def replace_content(&block)
138+
@node.tap do |node|
139+
traverse_bottom_up(node) do |n|
140+
n.content = block.call(n)
141+
end
142+
end
143+
end
144+
145+
private
146+
def traverse_bottom_up(node)
147+
call_stack, processing_stack = [ node ], []
148+
149+
until call_stack.empty?
150+
node = call_stack.pop
151+
processing_stack.push(node)
152+
call_stack.concat node.children
153+
end
154+
155+
processing_stack.reverse_each do |node|
156+
yield node
157+
end
158+
end
159+
end
124160
end
125161
end

actiontext/test/unit/plain_text_conversion_test.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ class ActionText::PlainTextConversionTest < ActiveSupport::TestCase
120120
"Hello world!\nHow are you?",
121121
ActionText::Fragment.wrap("<div>Hello world!</div><div></div>").tap do |fragment|
122122
node = fragment.source.children.last
123-
1_000.times do
123+
10_000.times do
124124
child = node.clone
125125
child.parent = node
126126
node = child
@@ -130,6 +130,14 @@ class ActionText::PlainTextConversionTest < ActiveSupport::TestCase
130130
)
131131
end
132132

133+
test "preserves the original tree" do
134+
html = "<div>Hello <span>world!</span></div>"
135+
fragment = ActionText::Fragment.wrap(html)
136+
assert_no_changes -> { fragment.source.to_html } do
137+
assert_equal "Hello world!", fragment.to_plain_text
138+
end
139+
end
140+
133141
test "preserves non-linebreak whitespace after text" do
134142
assert_converted_to(
135143
"Hello world!",

0 commit comments

Comments
 (0)