Skip to content

Commit b404efb

Browse files
authored
Engine: Introduce SubtreeCompiler to compile part of a template (#2241)
This pull request introduces `Herb::Engine::SubtreeCompiler`, which compiles a template into Ruby that renders one part of it instead of all of it. Re-rendering a region means running the template again and keeping only the piece that changed. The piece cannot be compiled on its own, because what it renders depends on everything the template did before reaching it. ```ruby require "herb/engine/subtree_compiler" source = Herb::Engine::SubtreeCompiler.new(template, node_path: [4]).src view.instance_eval(source) #=> "<ul><li>a-assigned</li><li>b-assigned</li></ul>" ``` #### Everything runs, only the target is kept A local assigned earlier, a helper called for its side effect, the loop the target sits inside, all of it has to run for the target to render the same as it would have in a full render. So nothing is skipped. ```erb <% total = track("assigned") %> <div><%= track(@title) %></div> <ul><li><%= total %></li></ul> ``` Asking for the `<ul>` returns `<ul><li>assigned</li></ul>`. The assignment above it ran, both tracked calls ran, and the `<div>` rendered into a buffer that is thrown away. What changes is where the output goes. The engine writes every append through one buffer variable, so the target's output is collected by pointing that variable at the buffer being returned while the target renders and at a sink the rest of the time. Escaping, blocks, and control flow then need no special handling, because none of them know which buffer they are writing to. That is also why the escaping is right without any work, since the target is going back to the place it was cut from. Pruning the work that only fed discarded output is a separate question, and answering it needs to know which expressions the target actually depends on. Running everything is the answer that is correct without that analysis. #### Control flow around the target A target inside a loop renders once per iteration, and a target inside a branch that did not run renders nothing. ```erb <% @names.each do |name| %> <li><%= name %></li> <% end %> ``` Asking for the `<li>` with two items returns `<li>a</li><li>b</li>`, because the loop it sits in is part of what has to run. #### Addressing The target is named by `node_path`, the same path `SlotVisitor` records for a slot in [#1986](#1986) and `Herb::ActionView::TemplateDependencies` reports for a node, so a caller holding one from either has one that works here. An empty path is the whole document, which is tested for output identity against `Herb::Engine` itself. A path that leads nowhere raises `TargetNotFound` instead of compiling a template that silently renders nothing.
1 parent 32232e0 commit b404efb

3 files changed

Lines changed: 530 additions & 0 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# frozen_string_literal: true
2+
# typed: false
3+
4+
require_relative "../../herb"
5+
require_relative "../engine"
6+
7+
module Herb
8+
class Engine
9+
# Compiles a template into Ruby that renders one part of it rather than all of it.
10+
#
11+
# Re-rendering a region means running the template again and keeping only the piece that
12+
# changed. The piece cannot be compiled on its own, because what it renders depends on
13+
# everything the template did before reaching it:
14+
#
15+
# require "herb/engine/subtree_compiler"
16+
#
17+
# source = Herb::Engine::SubtreeCompiler.new(template, node_path: [0, 1]).src
18+
# view.instance_eval(source)
19+
# #=> "<ul><li>Marco</li></ul>"
20+
#
21+
# ## Everything runs, only the target is kept
22+
#
23+
# A local assigned earlier, a helper called for its side effect, the loop the target sits
24+
# inside: all of it has to run for the target to render the same as it would have. So nothing
25+
# is skipped. What changes is where the output goes.
26+
#
27+
# The engine writes every append through one buffer variable, so the target's output is
28+
# collected by pointing that variable at the buffer being returned while the target renders and
29+
# at a sink the rest of the time. Escaping, blocks, and control flow then need no special
30+
# handling, because none of them know which buffer they are writing to.
31+
#
32+
# Pruning the work that only fed discarded output is a separate question, and answering it
33+
# needs to know which expressions the target actually depends on. Running everything is the
34+
# answer that is correct without that analysis.
35+
#
36+
# ## Addressing
37+
#
38+
# The target is named by `node_path`, the same path `SlotVisitor` records for a slot and
39+
# `Herb::ActionView::TemplateDependencies` reports for a node, so a caller that has one from
40+
# either has one that works here.
41+
#
42+
# The path indexes a document's children, an element's body, and the bodies of the ERB nodes
43+
# that branch or repeat. It does not descend into an open tag, so an attribute is not
44+
# addressable, and the body of an `else` or a `when` is indexed as part of the node it hangs
45+
# off rather than on its own. Both match `SlotVisitor`, and both are worth changing in the two
46+
# places at once rather than in either alone.
47+
class SubtreeCompiler < Herb::Engine
48+
BUFFER = "__herb_subtree" #: String
49+
SINK = "__herb_sink" #: String
50+
51+
INDEXED_PROPERTIES = [:statements, :body, :children, :conditions].freeze #: Array[Symbol]
52+
53+
class TargetNotFound < Herb::Engine::CompilationError
54+
#: (Array[Integer]) -> void
55+
def initialize(node_path)
56+
super("No node at node_path #{node_path.inspect}")
57+
end
58+
end
59+
60+
class Compiler < Herb::Engine::Compiler
61+
#: (untyped, ?Hash[Symbol, untyped]) -> void
62+
def initialize(engine, options = {})
63+
super
64+
65+
@target = options[:node_path]
66+
@path = [] #: Array[Integer]
67+
@found = false
68+
69+
indexed = {} #: Hash[untyped, bool]
70+
@indexed = indexed.compare_by_identity
71+
end
72+
73+
#: () -> bool
74+
def found?
75+
@found
76+
end
77+
78+
#: (untyped) -> void
79+
def visit_document_node(node)
80+
return indexing(node.children) { super } unless @target.empty?
81+
82+
@found = true
83+
84+
@tokens << [:subtree, "", nil, :enter]
85+
indexing(node.children) { super }
86+
@tokens << [:subtree, "", nil, :leave]
87+
end
88+
89+
#: (untyped) -> void
90+
def visit_html_element_node(node)
91+
indexing(node.body) { super }
92+
end
93+
94+
#: (untyped) -> void
95+
def visit_html_conditional_element_node(node)
96+
indexing(node.body) { super }
97+
end
98+
99+
#: (untyped) -> void
100+
def visit_erb_if_node(node)
101+
branching(node) { super }
102+
end
103+
104+
#: (untyped) -> void
105+
def visit_erb_unless_node(node)
106+
branching(node) { super }
107+
end
108+
109+
#: (untyped) -> void
110+
def visit_erb_case_node(node)
111+
branching(node) { super }
112+
end
113+
114+
#: (untyped) -> void
115+
def visit_erb_block_node(node)
116+
branching(node) { super }
117+
end
118+
119+
#: (untyped) -> void
120+
def visit_erb_iteration_block_node(node)
121+
branching(node) { super }
122+
end
123+
124+
#: (untyped) -> void
125+
def visit_erb_while_node(node)
126+
branching(node) { super }
127+
end
128+
129+
#: (untyped) -> void
130+
def visit_erb_until_node(node)
131+
branching(node) { super }
132+
end
133+
134+
#: (untyped) -> void
135+
def visit_erb_for_node(node)
136+
branching(node) { super }
137+
end
138+
139+
#: (Array[untyped]) -> void
140+
def visit_all(nodes)
141+
return super unless @indexed.key?(nodes)
142+
143+
nodes.each_with_index do |child, index|
144+
@path.push(index)
145+
146+
if @path == @target
147+
@found = true
148+
149+
@tokens << [:subtree, "", nil, :enter]
150+
visit(child)
151+
@tokens << [:subtree, "", nil, :leave]
152+
else
153+
visit(child)
154+
end
155+
156+
@path.pop
157+
end
158+
end
159+
160+
#: () -> void
161+
def generate_output
162+
optimize_tokens(@tokens).each do |type, value, context, escaped|
163+
case type
164+
when :text then @engine.send(:add_text, value)
165+
when :code then @engine.send(:add_code, value)
166+
when :subtree then @engine.send(:add_subtree_boundary, escaped)
167+
when :expr, :expr_escaped
168+
indicator = indicator_for(type)
169+
170+
if context_aware_context?(context)
171+
@engine.send(:add_context_aware_expression, indicator, value, context)
172+
else
173+
@engine.send(:add_expression, indicator, value)
174+
end
175+
when :expr_block, :expr_block_escaped
176+
@engine.send(:add_expression_block, indicator_for(type), value)
177+
when :expr_block_end
178+
@engine.send(:add_expression_block_end, value, escaped: escaped)
179+
end
180+
end
181+
end
182+
183+
private
184+
185+
#: (*untyped) { () -> void } -> void
186+
def indexing(*arrays)
187+
marked = arrays.select { |array| array.is_a?(Array) }
188+
marked.each { |array| @indexed[array] = true }
189+
190+
yield
191+
end
192+
193+
#: (untyped) { () -> void } -> void
194+
def branching(node, &)
195+
arrays = INDEXED_PROPERTIES.filter_map { |property|
196+
node.send(property) if node.respond_to?(property)
197+
}
198+
199+
indexing(*arrays, &)
200+
end
201+
end
202+
203+
attr_reader :node_path #: Array[Integer]
204+
205+
#: (String, ?Hash[Symbol, untyped]) -> void
206+
def initialize(input, properties = {})
207+
@node_path = properties[:node_path] || []
208+
@found = false
209+
210+
given = properties[:parser_options] || {} #: Hash[Symbol, untyped]
211+
parser_options = given.merge(iteration_nodes: true)
212+
213+
super(
214+
input,
215+
properties.merge(
216+
bufvar: SINK,
217+
preamble: "#{BUFFER} = ::String.new; #{SINK} = ::String.new;",
218+
postamble: "#{BUFFER}\n",
219+
parser_options: parser_options
220+
)
221+
)
222+
end
223+
224+
#: () -> untyped
225+
def compiler_class
226+
Compiler
227+
end
228+
229+
#: () -> String
230+
def inspect
231+
"#<#{self.class.name}>"
232+
end
233+
234+
protected
235+
236+
#: (Symbol) -> void
237+
def add_subtree_boundary(boundary)
238+
entering = boundary == :enter
239+
240+
@found = true if entering
241+
@bufvar = entering ? BUFFER : SINK
242+
end
243+
244+
#: (String) -> void
245+
def add_postamble(postamble)
246+
raise TargetNotFound, node_path unless @found
247+
248+
super
249+
end
250+
end
251+
end
252+
end

‎sig/herb/engine/subtree_compiler.rbs‎

Lines changed: 128 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)