diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 1ef272d46..f98079abb 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -64,6 +64,33 @@ def warnings @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 << %() + end + output + end + + def render_after_tags + render_after_tags_to_output_buffer(+'') + end + def strainer @strainer ||= @environment.create_strainer(self, @filters) end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 26004d647..2acfc038a 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -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 @@ def initialize(tag_name, markup, options) 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 @@ def render_to_output_buffer(context, output) 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 @@ def render_tag(context, output) 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 << %() + 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 diff --git a/proposals/render-after.md b/proposals/render-after.md new file mode 100644 index 000000000..eb03ea873 --- /dev/null +++ b/proposals/render-after.md @@ -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 + +``` + +Later, flushing the deferred renders produces replacement patches. The target shape should be compatible with the direction of Declarative Partial Updates. For example: + +```html + +``` + +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 +# => "" +``` + +## 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 + + +``` + +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 +

{{ product.title }}

+ +{% render 'price', product: product %} + +
+ {% render 'recommendations' after, product: product %} +
+``` + +Initial output: + +```html +

Snowboard

+ +$699.00 + +
+ +
+``` + +Deferred patch output: + +```html + +``` + +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. diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index eda80a040..6a696c629 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -46,6 +46,70 @@ def test_render_accepts_multiple_named_arguments ) 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 after', template.render(context)) + assert_equal('', 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('', template.render(context)) + assert_equal('', 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('', template.render(context)) + assert_equal('', 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('', template.render(context)) + assert_equal('', 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('', template.render(context)) + assert_equal('', 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( '',