|
| 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