Skip to content

Commit c4902e7

Browse files
committed
Add deferred partial rendering with Declarative Partial Updates
Introduce an `after` modifier for the render tag using Google's proposed Declarative Partial Updates format. Initial rendering emits processing-instruction markers, queues isolated partial renders, and flushes `<template for=...>` replacement patches from the context.
1 parent c6f05ea commit c4902e7

4 files changed

Lines changed: 336 additions & 11 deletions

File tree

lib/liquid/context.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,33 @@ def warnings
6464
@warnings ||= []
6565
end
6666

67+
def after_render_jobs
68+
@registers.static[:after_render_jobs] ||= []
69+
end
70+
71+
def next_after_render_id
72+
@registers.static[:after_render_sequence] ||= 0
73+
@registers.static[:after_render_sequence] += 1
74+
"liquid-after-#{@registers.static[:after_render_sequence]}"
75+
end
76+
77+
def enqueue_after_render(job)
78+
after_render_jobs << job
79+
end
80+
81+
def render_after_tags_to_output_buffer(output)
82+
while (job = after_render_jobs.shift)
83+
output << %(<template for="#{job[:id]}">)
84+
job[:renderer].call(output)
85+
output << %(</template>)
86+
end
87+
output
88+
end
89+
90+
def render_after_tags
91+
render_after_tags_to_output_buffer(+'')
92+
end
93+
6794
def strainer
6895
@strainer ||= @environment.create_strainer(self, @filters)
6996
end

lib/liquid/tags/render.rb

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ module Liquid
2727
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
2828
class Render < Tag
2929
FOR = 'for'
30-
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
30+
AFTER = 'after'
31+
AFTER_MARKUP = /\s+#{AFTER}(?=\s|,|\z)/o
32+
WITH_OR_FOR_MARKUP = /\s+(with|#{FOR})\s+(#{QuotedFragment}+)/o
33+
ALIAS_MARKUP = /\s+(?:as)\s+(#{VariableSegment}+)/o
34+
SYNTAX = /(#{QuotedString}+)(#{AFTER_MARKUP})?(#{WITH_OR_FOR_MARKUP})?(#{ALIAS_MARKUP})?/o
3135

3236
disable_tags "include"
3337

@@ -39,10 +43,11 @@ def initialize(tag_name, markup, options)
3943
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
4044

4145
template_name = Regexp.last_match(1)
42-
with_or_for = Regexp.last_match(3)
43-
variable_name = Regexp.last_match(4)
46+
@after = !!Regexp.last_match(2)
47+
with_or_for = Regexp.last_match(4)
48+
variable_name = Regexp.last_match(5)
4449

45-
@alias_name = Regexp.last_match(6)
50+
@alias_name = Regexp.last_match(7)
4651
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
4752
@template_name_expr = parse_expression(template_name)
4853
@is_for_loop = (with_or_for == FOR)
@@ -61,6 +66,10 @@ def render_to_output_buffer(context, output)
6166
render_tag(context, output)
6267
end
6368

69+
def after?
70+
@after
71+
end
72+
6473
def render_tag(context, output)
6574
# The expression should be a String literal, which parses to a String object
6675
template_name = @template_name_expr
@@ -74,26 +83,43 @@ def render_tag(context, output)
7483

7584
context_variable_name = @alias_name || template_name.split('/').last
7685

77-
render_partial_func = ->(var, forloop) {
86+
evaluated_attributes = @attributes.transform_values { |value| context.evaluate(value) }
87+
88+
render_partial_func = ->(var, forloop, render_output) {
7889
inner_context = context.new_isolated_subcontext
7990
inner_context.template_name = partial.name
8091
inner_context.partial = true
8192
inner_context['forloop'] = forloop if forloop
8293

83-
@attributes.each do |key, value|
84-
inner_context[key] = context.evaluate(value)
94+
evaluated_attributes.each do |key, value|
95+
inner_context[key] = value
8596
end
8697
inner_context[context_variable_name] = var unless var.nil?
87-
partial.render_to_output_buffer(inner_context, output)
98+
partial.render_to_output_buffer(inner_context, render_output)
8899
forloop&.send(:increment!)
89100
}
90101

91102
variable = @variable_name_expr ? context.evaluate(@variable_name_expr) : nil
92-
if @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
103+
104+
if @after
105+
id = context.next_after_render_id
106+
context.enqueue_after_render(
107+
id: id,
108+
renderer: ->(after_output) {
109+
if @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
110+
forloop = Liquid::ForloopDrop.new(template_name, variable.count, nil)
111+
variable.each { |var| render_partial_func.call(var, forloop, after_output) }
112+
else
113+
render_partial_func.call(variable, nil, after_output)
114+
end
115+
}
116+
)
117+
output << %(<?marker name="#{id}">)
118+
elsif @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
93119
forloop = Liquid::ForloopDrop.new(template_name, variable.count, nil)
94-
variable.each { |var| render_partial_func.call(var, forloop) }
120+
variable.each { |var| render_partial_func.call(var, forloop, output) }
95121
else
96-
render_partial_func.call(variable, nil)
122+
render_partial_func.call(variable, nil, output)
97123
end
98124

99125
output

proposals/render-after.md

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Proposal: Deferred `render` with `after`
2+
3+
## Summary
4+
5+
Add an optional `after` modifier to Liquid's `{% render %}` tag:
6+
7+
```liquid
8+
{% render 'product-card' after, product: product %}
9+
```
10+
11+
When `after` is present, Liquid does not render the partial inline. Instead, it emits a stable HTML placeholder marker into the output and records enough information on the current `Liquid::Context` to render the partial later. A new context API then renders all deferred partials, ideally as a stream of out-of-order HTML replacement patches.
12+
13+
This is inspired by Chrome's Declarative Partial Updates proposal: https://developer.chrome.com/blog/declarative-partial-updates. The browser-side idea is to let HTML declare patch targets and stream their replacement content later, enabling the initial shell to be sent quickly while slower islands arrive when ready.
14+
15+
For Liquid, the equivalent is server-side syntax for declaring that a snippet can be delayed without changing template structure.
16+
17+
## Motivation
18+
19+
Liquid templates often have a mix of cheap layout work and expensive isolated snippets. Today, an expensive snippet blocks all subsequent output because `{% render %}` is synchronous and inline.
20+
21+
`render after` would allow templates to produce the main document quickly, reserve exact DOM locations for deferred snippets, and render those snippets later using the same Liquid render semantics.
22+
23+
Example use cases:
24+
25+
- Product recommendations below the fold.
26+
- Expensive merchandising or personalization blocks.
27+
- Analytics or SEO metadata fragments that can be patched into known locations.
28+
- App blocks where the outer page shell should not wait on the block.
29+
30+
## Goals
31+
32+
- Add a small, Liquid-native API for deferring isolated snippet rendering.
33+
- Preserve existing `{% render %}` isolation semantics.
34+
- Emit processing-instruction placeholders that can be targeted by a later replacement patch.
35+
- Store deferred render work in `Liquid::Context`.
36+
- Add a context method to flush/enumerate/render deferred work.
37+
- Keep the first prototype simple and non-streaming, while shaping the API so true streaming can be added later.
38+
39+
## Non-goals
40+
41+
- Implement browser support for Declarative Partial Updates.
42+
- Require JavaScript for the Liquid-side primitive.
43+
- Make arbitrary tags asynchronous.
44+
- Allow deferred snippets to mutate the parent scope after the placeholder is emitted.
45+
- Solve scheduling, prioritization, cancellation, or parallel execution in the first prototype.
46+
47+
## Syntax
48+
49+
The proposed syntax is:
50+
51+
```liquid
52+
{% render 'snippet' after %}
53+
{% render 'snippet' after, product: product %}
54+
{% render 'snippet' after with product as item %}
55+
{% render 'snippet' after for products as product %}
56+
```
57+
58+
`after` is a render modifier with no value. It is intentionally boolean and reserved in this position.
59+
60+
The prototype supports bare `after` immediately after the rendered template name, because it reads like a render modifier rather than data passed into the snippet. `after: value` remains a normal named argument passed to the snippet.
61+
62+
## Output shape
63+
64+
When a deferred render is encountered, Liquid emits a Chrome-style processing-instruction placeholder marker with a unique id:
65+
66+
```html
67+
<?marker name="liquid-after-1">
68+
```
69+
70+
Later, flushing the deferred renders produces replacement patches. The target shape should be compatible with the direction of Declarative Partial Updates. For example:
71+
72+
```html
73+
<template for="liquid-after-1">
74+
...rendered snippet HTML...
75+
</template>
76+
```
77+
78+
The exact patch attribute names should track the platform proposal as it evolves. Until browser APIs stabilize, Liquid can expose a server-side patch format behind a small formatter object.
79+
80+
For the prototype, the replacement payload is a concatenated HTML patch string:
81+
82+
```ruby
83+
context.render_after_tags
84+
# => "<template for=...>...</template>"
85+
```
86+
87+
## Semantics
88+
89+
### Evaluation timing
90+
91+
When `{% render 'snippet' after ... %}` is encountered:
92+
93+
1. Liquid evaluates the snippet name expression.
94+
2. Liquid evaluates the `with` / `for` expression, if present.
95+
3. Liquid evaluates all named render arguments.
96+
4. Liquid records a deferred render job containing the evaluated values and render metadata.
97+
5. Liquid emits a placeholder marker.
98+
99+
This means deferred renders capture values at enqueue time, not flush time. That avoids surprising behavior when variables change later in the template.
100+
101+
### Isolation
102+
103+
Deferred render jobs should use the same isolation semantics as normal `{% render %}`:
104+
105+
- The snippet receives only explicitly-passed variables plus globals/environments available to render today.
106+
- Variables assigned inside the snippet do not leak into the parent template.
107+
- The `include` tag remains disabled inside rendered snippets.
108+
109+
### Ordering
110+
111+
The queue is FIFO by default. Placeholder ids are monotonically increasing per context render:
112+
113+
```html
114+
<?marker name="liquid-after-1">
115+
<?marker name="liquid-after-2">
116+
```
117+
118+
The streaming API may later render jobs as they become ready, but the prototype can preserve source order.
119+
120+
### Error handling
121+
122+
Deferred renders should use Liquid's existing error handling through `Context#handle_error` and `exception_renderer`.
123+
124+
Open question: if an error occurs while flushing deferred renders after the main template was already sent, should the replacement patch contain the rendered error string, an empty patch, or an out-of-band error? The prototype should match inline render behavior and place the rendered error into the patch body.
125+
126+
## Proposed API
127+
128+
Add queue APIs to `Liquid::Context`:
129+
130+
```ruby
131+
context.enqueue_after_render(job) # internal
132+
context.after_render_jobs # inspection/testing
133+
context.render_after_tags # prototype: returns a string of patches
134+
context.render_after_tags_to_output_buffer(output) # streaming-ready shape
135+
```
136+
137+
Possible streaming-oriented API:
138+
139+
```ruby
140+
context.each_after_render_patch do |patch|
141+
response.write(patch)
142+
end
143+
```
144+
145+
or:
146+
147+
```ruby
148+
context.render_after_tags_to_output_buffer(response_stream)
149+
```
150+
151+
The first implementation may buffer each snippet internally. The API should still write to an output object so callers can later stream each completed patch without changing template code.
152+
153+
## Example
154+
155+
Template:
156+
157+
```liquid
158+
<h1>{{ product.title }}</h1>
159+
160+
{% render 'price', product: product %}
161+
162+
<section>
163+
{% render 'recommendations' after, product: product %}
164+
</section>
165+
```
166+
167+
Initial output:
168+
169+
```html
170+
<h1>Snowboard</h1>
171+
172+
<span>$699.00</span>
173+
174+
<section>
175+
<?marker name="liquid-after-1">
176+
</section>
177+
```
178+
179+
Deferred patch output:
180+
181+
```html
182+
<template for="liquid-after-1">
183+
<ul class="recommendations">...</ul>
184+
</template>
185+
```
186+
187+
A Rack-like integration could do:
188+
189+
```ruby
190+
context = Liquid::Context.build(...)
191+
body = template.render!(context)
192+
response.write(body)
193+
context.render_after_tags_to_output_buffer(response)
194+
```
195+
196+
The prototype can buffer `body` first. A production integration would stream `body` immediately, then stream each deferred patch as soon as it completes.
197+
198+
## Compatibility
199+
200+
Existing templates are unaffected unless they use bare `after` immediately after the rendered template name.
201+
202+
Because bare `after` becomes reserved syntax for the render tag in that position, this could conflict with unusual templates that currently rely on that token being ignored. Snippets currently receiving an `after:` keyword argument continue to work:
203+
204+
```liquid
205+
{% render 'divider', after: 'label' %}
206+
```
207+
208+
This proposal only reserves bare `after`; `after: value` continues to be passed as a normal snippet attribute. That minimizes compatibility risk.

0 commit comments

Comments
 (0)