mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 15:30:39 -07:00
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.
This commit is contained in:
@@ -64,6 +64,33 @@ module Liquid
|
||||
@warnings ||= []
|
||||
end
|
||||
|
||||
def after_render_jobs
|
||||
@registers.static[:after_render_jobs] ||= []
|
||||
end
|
||||
|
||||
def next_after_render_id
|
||||
@registers.static[:after_render_sequence] ||= 0
|
||||
@registers.static[:after_render_sequence] += 1
|
||||
"liquid-after-#{@registers.static[:after_render_sequence]}"
|
||||
end
|
||||
|
||||
def enqueue_after_render(job)
|
||||
after_render_jobs << job
|
||||
end
|
||||
|
||||
def render_after_tags_to_output_buffer(output)
|
||||
while (job = after_render_jobs.shift)
|
||||
output << %(<template for="#{job[:id]}">)
|
||||
job[:renderer].call(output)
|
||||
output << %(</template>)
|
||||
end
|
||||
output
|
||||
end
|
||||
|
||||
def render_after_tags
|
||||
render_after_tags_to_output_buffer(+'')
|
||||
end
|
||||
|
||||
def strainer
|
||||
@strainer ||= @environment.create_strainer(self, @filters)
|
||||
end
|
||||
|
||||
+37
-11
@@ -27,7 +27,11 @@ module Liquid
|
||||
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
|
||||
class Render < Tag
|
||||
FOR = 'for'
|
||||
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
||||
AFTER = 'after'
|
||||
AFTER_MARKUP = /\s+#{AFTER}(?=\s|,|\z)/o
|
||||
WITH_OR_FOR_MARKUP = /\s+(with|#{FOR})\s+(#{QuotedFragment}+)/o
|
||||
ALIAS_MARKUP = /\s+(?:as)\s+(#{VariableSegment}+)/o
|
||||
SYNTAX = /(#{QuotedString}+)(#{AFTER_MARKUP})?(#{WITH_OR_FOR_MARKUP})?(#{ALIAS_MARKUP})?/o
|
||||
|
||||
disable_tags "include"
|
||||
|
||||
@@ -39,10 +43,11 @@ module Liquid
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
@after = !!Regexp.last_match(2)
|
||||
with_or_for = Regexp.last_match(4)
|
||||
variable_name = Regexp.last_match(5)
|
||||
|
||||
@alias_name = Regexp.last_match(6)
|
||||
@alias_name = Regexp.last_match(7)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
@@ -61,6 +66,10 @@ module Liquid
|
||||
render_tag(context, output)
|
||||
end
|
||||
|
||||
def after?
|
||||
@after
|
||||
end
|
||||
|
||||
def render_tag(context, output)
|
||||
# The expression should be a String literal, which parses to a String object
|
||||
template_name = @template_name_expr
|
||||
@@ -74,26 +83,43 @@ module Liquid
|
||||
|
||||
context_variable_name = @alias_name || template_name.split('/').last
|
||||
|
||||
render_partial_func = ->(var, forloop) {
|
||||
evaluated_attributes = @attributes.transform_values { |value| context.evaluate(value) }
|
||||
|
||||
render_partial_func = ->(var, forloop, render_output) {
|
||||
inner_context = context.new_isolated_subcontext
|
||||
inner_context.template_name = partial.name
|
||||
inner_context.partial = true
|
||||
inner_context['forloop'] = forloop if forloop
|
||||
|
||||
@attributes.each do |key, value|
|
||||
inner_context[key] = context.evaluate(value)
|
||||
evaluated_attributes.each do |key, value|
|
||||
inner_context[key] = value
|
||||
end
|
||||
inner_context[context_variable_name] = var unless var.nil?
|
||||
partial.render_to_output_buffer(inner_context, output)
|
||||
partial.render_to_output_buffer(inner_context, render_output)
|
||||
forloop&.send(:increment!)
|
||||
}
|
||||
|
||||
variable = @variable_name_expr ? context.evaluate(@variable_name_expr) : nil
|
||||
if @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
|
||||
|
||||
if @after
|
||||
id = context.next_after_render_id
|
||||
context.enqueue_after_render(
|
||||
id: id,
|
||||
renderer: ->(after_output) {
|
||||
if @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
|
||||
forloop = Liquid::ForloopDrop.new(template_name, variable.count, nil)
|
||||
variable.each { |var| render_partial_func.call(var, forloop, after_output) }
|
||||
else
|
||||
render_partial_func.call(variable, nil, after_output)
|
||||
end
|
||||
}
|
||||
)
|
||||
output << %(<?marker name="#{id}">)
|
||||
elsif @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count)
|
||||
forloop = Liquid::ForloopDrop.new(template_name, variable.count, nil)
|
||||
variable.each { |var| render_partial_func.call(var, forloop) }
|
||||
variable.each { |var| render_partial_func.call(var, forloop, output) }
|
||||
else
|
||||
render_partial_func.call(variable, nil)
|
||||
render_partial_func.call(variable, nil, output)
|
||||
end
|
||||
|
||||
output
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Proposal: Deferred `render` with `after`
|
||||
|
||||
## Summary
|
||||
|
||||
Add an optional `after` modifier to Liquid's `{% render %}` tag:
|
||||
|
||||
```liquid
|
||||
{% render 'product-card' after, product: product %}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
For Liquid, the equivalent is server-side syntax for declaring that a snippet can be delayed without changing template structure.
|
||||
|
||||
## Motivation
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
Example use cases:
|
||||
|
||||
- Product recommendations below the fold.
|
||||
- Expensive merchandising or personalization blocks.
|
||||
- Analytics or SEO metadata fragments that can be patched into known locations.
|
||||
- App blocks where the outer page shell should not wait on the block.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a small, Liquid-native API for deferring isolated snippet rendering.
|
||||
- Preserve existing `{% render %}` isolation semantics.
|
||||
- Emit processing-instruction placeholders that can be targeted by a later replacement patch.
|
||||
- Store deferred render work in `Liquid::Context`.
|
||||
- Add a context method to flush/enumerate/render deferred work.
|
||||
- Keep the first prototype simple and non-streaming, while shaping the API so true streaming can be added later.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Implement browser support for Declarative Partial Updates.
|
||||
- Require JavaScript for the Liquid-side primitive.
|
||||
- Make arbitrary tags asynchronous.
|
||||
- Allow deferred snippets to mutate the parent scope after the placeholder is emitted.
|
||||
- Solve scheduling, prioritization, cancellation, or parallel execution in the first prototype.
|
||||
|
||||
## Syntax
|
||||
|
||||
The proposed syntax is:
|
||||
|
||||
```liquid
|
||||
{% render 'snippet' after %}
|
||||
{% render 'snippet' after, product: product %}
|
||||
{% render 'snippet' after with product as item %}
|
||||
{% render 'snippet' after for products as product %}
|
||||
```
|
||||
|
||||
`after` is a render modifier with no value. It is intentionally boolean and reserved in this position.
|
||||
|
||||
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.
|
||||
|
||||
## Output shape
|
||||
|
||||
When a deferred render is encountered, Liquid emits a Chrome-style processing-instruction placeholder marker with a unique id:
|
||||
|
||||
```html
|
||||
<?marker name="liquid-after-1">
|
||||
```
|
||||
|
||||
Later, flushing the deferred renders produces replacement patches. The target shape should be compatible with the direction of Declarative Partial Updates. For example:
|
||||
|
||||
```html
|
||||
<template for="liquid-after-1">
|
||||
...rendered snippet HTML...
|
||||
</template>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
For the prototype, the replacement payload is a concatenated HTML patch string:
|
||||
|
||||
```ruby
|
||||
context.render_after_tags
|
||||
# => "<template for=...>...</template>"
|
||||
```
|
||||
|
||||
## Semantics
|
||||
|
||||
### Evaluation timing
|
||||
|
||||
When `{% render 'snippet' after ... %}` is encountered:
|
||||
|
||||
1. Liquid evaluates the snippet name expression.
|
||||
2. Liquid evaluates the `with` / `for` expression, if present.
|
||||
3. Liquid evaluates all named render arguments.
|
||||
4. Liquid records a deferred render job containing the evaluated values and render metadata.
|
||||
5. Liquid emits a placeholder marker.
|
||||
|
||||
This means deferred renders capture values at enqueue time, not flush time. That avoids surprising behavior when variables change later in the template.
|
||||
|
||||
### Isolation
|
||||
|
||||
Deferred render jobs should use the same isolation semantics as normal `{% render %}`:
|
||||
|
||||
- The snippet receives only explicitly-passed variables plus globals/environments available to render today.
|
||||
- Variables assigned inside the snippet do not leak into the parent template.
|
||||
- The `include` tag remains disabled inside rendered snippets.
|
||||
|
||||
### Ordering
|
||||
|
||||
The queue is FIFO by default. Placeholder ids are monotonically increasing per context render:
|
||||
|
||||
```html
|
||||
<?marker name="liquid-after-1">
|
||||
<?marker name="liquid-after-2">
|
||||
```
|
||||
|
||||
The streaming API may later render jobs as they become ready, but the prototype can preserve source order.
|
||||
|
||||
### Error handling
|
||||
|
||||
Deferred renders should use Liquid's existing error handling through `Context#handle_error` and `exception_renderer`.
|
||||
|
||||
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.
|
||||
|
||||
## Proposed API
|
||||
|
||||
Add queue APIs to `Liquid::Context`:
|
||||
|
||||
```ruby
|
||||
context.enqueue_after_render(job) # internal
|
||||
context.after_render_jobs # inspection/testing
|
||||
context.render_after_tags # prototype: returns a string of patches
|
||||
context.render_after_tags_to_output_buffer(output) # streaming-ready shape
|
||||
```
|
||||
|
||||
Possible streaming-oriented API:
|
||||
|
||||
```ruby
|
||||
context.each_after_render_patch do |patch|
|
||||
response.write(patch)
|
||||
end
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```ruby
|
||||
context.render_after_tags_to_output_buffer(response_stream)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Example
|
||||
|
||||
Template:
|
||||
|
||||
```liquid
|
||||
<h1>{{ product.title }}</h1>
|
||||
|
||||
{% render 'price', product: product %}
|
||||
|
||||
<section>
|
||||
{% render 'recommendations' after, product: product %}
|
||||
</section>
|
||||
```
|
||||
|
||||
Initial output:
|
||||
|
||||
```html
|
||||
<h1>Snowboard</h1>
|
||||
|
||||
<span>$699.00</span>
|
||||
|
||||
<section>
|
||||
<?marker name="liquid-after-1">
|
||||
</section>
|
||||
```
|
||||
|
||||
Deferred patch output:
|
||||
|
||||
```html
|
||||
<template for="liquid-after-1">
|
||||
<ul class="recommendations">...</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
A Rack-like integration could do:
|
||||
|
||||
```ruby
|
||||
context = Liquid::Context.build(...)
|
||||
body = template.render!(context)
|
||||
response.write(body)
|
||||
context.render_after_tags_to_output_buffer(response)
|
||||
```
|
||||
|
||||
The prototype can buffer `body` first. A production integration would stream `body` immediately, then stream each deferred patch as soon as it completes.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing templates are unaffected unless they use bare `after` immediately after the rendered template name.
|
||||
|
||||
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:
|
||||
|
||||
```liquid
|
||||
{% render 'divider', after: 'label' %}
|
||||
```
|
||||
|
||||
This proposal only reserves bare `after`; `after: value` continues to be passed as a normal snippet attribute. That minimizes compatibility risk.
|
||||
@@ -46,6 +46,70 @@ class RenderTagTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_after_defers_partial_until_context_flush
|
||||
file_system = StubFileSystem.new('snippet' => 'deferred {{ value }}')
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
template = Liquid::Template.parse('before {% render "snippet" after, value: 1 %} after', environment: environment)
|
||||
registers = Liquid::Registers.new(file_system: file_system)
|
||||
context = Liquid::Context.build(registers: registers, environment: environment)
|
||||
|
||||
assert_equal('before <?marker name="liquid-after-1"> after', template.render(context))
|
||||
assert_equal('<template for="liquid-after-1">deferred 1</template>', context.render_after_tags)
|
||||
assert_empty(context.after_render_jobs)
|
||||
end
|
||||
|
||||
def test_render_after_evaluates_attributes_when_enqueued
|
||||
file_system = StubFileSystem.new('snippet' => '{{ value }}')
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
template = Liquid::Template.parse('{% assign value = 1 %}{% render "snippet" after, value: value %}{% assign value = 2 %}', environment: environment)
|
||||
registers = Liquid::Registers.new(file_system: file_system)
|
||||
context = Liquid::Context.build(registers: registers, environment: environment)
|
||||
|
||||
assert_equal('<?marker name="liquid-after-1">', template.render(context))
|
||||
assert_equal('<template for="liquid-after-1">1</template>', context.render_after_tags)
|
||||
end
|
||||
|
||||
def test_render_after_supports_with_and_as
|
||||
file_system = StubFileSystem.new('snippet' => '{{ item }}')
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
template = Liquid::Template.parse('{% render "snippet" after with value as item %}', environment: environment)
|
||||
registers = Liquid::Registers.new(file_system: file_system)
|
||||
context = Liquid::Context.build(static_environments: { 'value' => 'captured' }, registers: registers, environment: environment)
|
||||
|
||||
assert_equal('<?marker name="liquid-after-1">', template.render(context))
|
||||
assert_equal('<template for="liquid-after-1">captured</template>', context.render_after_tags)
|
||||
end
|
||||
|
||||
def test_render_after_supports_for_and_as
|
||||
file_system = StubFileSystem.new('snippet' => '{{ forloop.index }}:{{ item }};')
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
template = Liquid::Template.parse('{% render "snippet" after for values as item %}', environment: environment)
|
||||
registers = Liquid::Registers.new(file_system: file_system)
|
||||
context = Liquid::Context.build(static_environments: { 'values' => ['a', 'b'] }, registers: registers, environment: environment)
|
||||
|
||||
assert_equal('<?marker name="liquid-after-1">', template.render(context))
|
||||
assert_equal('<template for="liquid-after-1">1:a;2:b;</template>', context.render_after_tags)
|
||||
end
|
||||
|
||||
def test_render_after_preserves_fifo_order
|
||||
file_system = StubFileSystem.new('snippet' => '{{ value }}')
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
template = Liquid::Template.parse('{% render "snippet" after, value: 1 %}{% render "snippet" after, value: 2 %}', environment: environment)
|
||||
registers = Liquid::Registers.new(file_system: file_system)
|
||||
context = Liquid::Context.build(registers: registers, environment: environment)
|
||||
|
||||
assert_equal('<?marker name="liquid-after-1"><?marker name="liquid-after-2">', template.render(context))
|
||||
assert_equal('<template for="liquid-after-1">1</template><template for="liquid-after-2">2</template>', context.render_after_tags)
|
||||
end
|
||||
|
||||
def test_render_after_colon_remains_a_named_argument
|
||||
assert_template_result(
|
||||
'later',
|
||||
'{% render "snippet", after: "later" %}',
|
||||
partials: { 'snippet' => '{{ after }}' },
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_does_not_inherit_parent_scope_variables
|
||||
assert_template_result(
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user