Phase 6: Static/Dynamic Splitting — Refined Plan (v2) #30
Closed
SeanTAllen
started this conversation in
Research
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Design: #3
Predecessors: #28, #29
This plan replaces Discussion #29. The core approach is the same -- split template output into static and dynamic parts, send only changed dynamics over the wire -- but the API is simpler, the implementation is leaner, and a critical dependency question is resolved: templates 0.3.2 already ships
render_to(sink),TemplateSink, andrender_split(). No separate templates PR is needed. Just bump the version.What Changed from #29
Discussion #29 was built on templates 0.3.1. It planned adding
render_split()to the templates library as a separate PR, releasing templates 0.4.0, then consuming it. Templates 0.3.2 already has all of that. This eliminates the critical-path dependency and the most complex step in the old plan.The API also changes substantially. #29 had views return a
RenderPartsvalue fromrender_parts(). This plan has the framework pass a sink to the view, which the view feeds toHtmlTemplate.render_to(). The sink collects statics and dynamics, caches statics across renders, and computes diffs in a single pass. No new public types are introduced.How It Works
Templates parse into static text and dynamic expressions internally.
HtmlTemplate.render_to(sink, values)drives a caller-suppliedTemplateSinkwith alternatingliteral()anddynamic_value()calls. The framework creates one persistent_RenderSinkper connection, passes it to the view'srender_parts()method, and the view feeds it to the template. The sink tracks previous dynamics, compares inline during the walk, and returns the minimal diff.Views opt in by overriding
render_parts()on theLiveViewtrait. The default returnsfalse(not supported), and the framework falls back to the existingrender()path. Views that override it get the split protocol. Views that don't change nothing.API
LiveView Trait Addition
The parameter type is
templates.TemplateSink ref, not a livery-specific type. The view depends only on the templates library's interface. The framework passes its internal_RenderSink(which implementsTemplateSink) but the view doesn't know or care about that -- it just passes the sink to_template.render_to().This is backward compatible. Existing views compile and work unchanged. The
handle_infoprecedent supports adding defaulted methods to traits.LiveComponentdoes not getrender_parts. Component output is flattened to a string for injection into the parent's template values viacomponent_html(). Component-level split rendering is deferred.No New Public Types
Zero new public types are introduced. The view method takes
TemplateSink ref(already public in templates) and returnsBool. The sink implementation, diff types, and all rendering state are internal. This follows the principle that it's easier to add public API later than to remove it.Compare with #29, which introduced
RenderPartsas a public class. That type existed only to shuttle data from the view to the framework -- it had no behavior beyondfull_html()reconstruction. With the sink approach, the framework already has the data; no shuttle type is needed.Internal Architecture
_RenderSink(new file:livery/_render_sink.pony)Implements
TemplateSink. One instance per connection, owned by_Connection. Handles everything: collecting statics and dynamics, caching statics across renders, verifying statics haven't changed, computing incremental diffs, and reconstructing full HTML.The sink uses a transaction pattern:
begin()before each render_template.render_to(sink, values)which callsliteral()anddynamic_value()alternatelyresult()to get the diff, orabandon()if the view returnedfalseCapability notes:
_temp_statics,_temp_dynamics, and_temp_changesareisofields. The destructive read pattern (let x: T val = _field = recover iso ... end) converts them tovalcleanly -- same pattern used by_SplitSinkin the templates library.push()onisoarrays works via automatic receiver recovery.full_html(), therecover valblock cannot accessthisfields. The fields_cached_staticsand_prev_dynamicsare read into localvalvariables (s,d) before the recover block. Both areArray[String] val(sendable), so the locals are accessible inside recover.Statics verification: On subsequent renders, each
literal()call compares the incoming text against the cached statics at the same index using!=(content comparison). Identity comparison (is) would be cheaper but doesn't work here --_TemplateWalkaccumulates literal segments in apending: String refbuffer and flushespending.clone()to the sink, producing a freshString valallocation each render. Same template, different object identity.Content comparison catches template switches reliably: if a view changes its
HtmlTemplateinstance mid-session (e.g., switching between a "logged in" and "logged out" template with the same variable count), the statics content will differ and the sink detects it, triggering a_FullRenderwith the new statics.A size check (cached count vs. new count) in
result()provides an O(1) early-out for structural template changes (different number of dynamic slots). The per-literal!=check catches same-structure-different-content changes. For typical templates (3-10 static fragments, each 10-100 bytes), verification adds a few microseconds per render.String.eq()does a size check first, so mismatches on length short-circuit immediately.One-pass diff: Each
dynamic_value()call simultaneously stores the value and compares against_prev_dynamicsat the same index. Changes are accumulated inline in_temp_changes. No separate diff pass after the walk._RenderDiffTypes (inlivery/_render_sink.pony)Wire Protocol Changes (
livery/_wire_protocol.pony)Two new encoding functions alongside the existing
encode_render:Wire format:
The existing
encode_render(html)is preserved for the full-HTML fallback path._ConnectionChanges (livery/_connection.pony)Add
let _render_sink: _RenderSink ref = _RenderSinkas a field.Extract the dual render path into a
_try_split_renderhelper to avoid duplicating the logic betweenon_openand_maybe_rerender:Both
on_openand_maybe_rerenderuse this helper:on_openrender section (replaces lines 62-70):_maybe_rerender-- with fallback transition handling:The
_render_sink.clear()call in the fallback path discards stale cached state when a view transitions from split rendering back to full-HTML. Without this, a subsequent switch back to split rendering would compare against stale dynamics from a previous split session.JS Client Changes
client/src/wire.js-- two new cases indecodeServerMessage:The existing
"render"case is preserved.client/src/live-view.js-- add_staticsand_dynamicsstate, extract_applyHtml, add_assembleHtml:Key behaviors:
_staticsand_dynamicsare cleared inonOpen.render_diffbeforerender_fullis silently dropped. The guard prevents corrupted state.renderclears split state. Handles fallback transition cleanly.Invariants
statics.size() == dynamics.size() + 1always holds. This is enforced by the templates library's_TemplateWalkinterleaving guarantee.statics[0] + dynamics[0] + statics[1] + ... + statics[N]._RenderSinkviarender_to()and reconstructing withfull_html()produces the same string asHtmlTemplate.render()for identical values. This is the core correctness property._Connectionactor = fresh_RenderSink. Server sends statics + all dynamics on every new connection.HtmlTemplate's contextual auto-escaping. The client concatenates pre-escaped strings without further processing.render(). The split protocol is WebSocket-only.Performance Analysis
Wire Bandwidth
render_full(first)render_diff(subsequent)The first message is roughly 18% larger than full-HTML due to the statics array. Break-even occurs after 2 renders for any view. Every interactive view gets more than 2 renders per connection.
Memory Per Connection
_RenderSinkstores: cached statics reference (8 bytes), previous dynamics array (~8N bytes of pointers + string data), transient arrays (reset each render). Counter: trivial. Form (7 slots): ~56 bytes of pointers. A 50-slot template: ~400 bytes of pointers.CPU: One-Pass Diff
Each
dynamic_value()call does one array lookup and one string comparison against the previous value. For N slots, that's N comparisons during the walk -- no separate pass. Statics verification adds N+1 content comparisons (!=) on static fragments. For typical templates (N < 20, statics averaging 10-100 bytes each),String.eq()short-circuits on size mismatch and the total verification cost is low microseconds -- negligible relative to template evaluation.Interface Dispatch
HtmlTemplate.render_to()dispatchesliteral()anddynamic_value()calls through theTemplateSinkinterface -- that's 2N+1 virtual dispatch calls per render. This cost is inherent to usingrender_to(), not specific to our API choice. For N=20, roughly 40ns of dispatch overhead -- orders of magnitude below template evaluation cost.Steps
Step 1: Update Templates Dependency
Update
corral.jsonfrom templates 0.3.1 to 0.3.2. Runmake cleanbefore rebuilding -- when a dependency changes versions,corral fetchmay leave stale files from the old checkout in_corral/.Templates 0.3.2 already provides
TemplateSink,HtmlTemplate.render_to(sink, values), andHtmlTemplate.render_split(values). No separate templates PR is needed.Step 2: Add
render_partsto LiveViewFile:
livery/live_view.ponyAdd the defaulted method as shown in the API section above.
live_view.ponycurrently only importsuse json = "json"-- adduse templates = "templates"for theTemplateSinkreference in the method signature.Step 3: Add
_RenderSinkand_RenderDiffTypesNew file:
livery/_render_sink.ponyNeeds
use templates = "templates"for theTemplateSinkreference. Contains_RenderSink,_RenderDiff,_FullRender,_SlotDiff,_NoChangeas shown in the Internal Architecture section.Step 4: Update Wire Protocol
File:
livery/_wire_protocol.ponyAdd
encode_render_full()andencode_render_diff()as shown above.Step 5: Update
_Connectionfor Split RenderingFile:
livery/_connection.ponyAdd
_render_sinkfield,_try_split_renderhelper, and updateon_openand_maybe_rerenderas shown above.Step 6: No Changes to Component Registry or PageRenderer
Components render to full HTML strings via
render(). Component output occupies one dynamic slot in the parent. Component-level split rendering is deferred.PageRenderercallsview.render(assigns)and returns full HTML. The split protocol is_Connection-only.Step 7: Update JS Client
Files:
client/src/wire.js,client/src/live-view.jsChanges as shown above.
Step 8: Update Examples
All five examples add
render_partsoverrides. The examples use unqualifieduse "templates", so the parameter type isTemplateSink ref(nottemplates.TemplateSink refas in the library). The pattern is simple for most:Todo example needs a shared value preparation helper since it builds
items_htmlfrom component HTML:Update
examples/README.mdto mention that examples demonstrate split rendering.Step 9: Tests
Build and run:
make test ssl=openssl_3.0.x(server),make client-test(JS). All new test classes get\nodoc\. Counterfactual testing after each new test passes._RenderSinkTests_TestRenderSinkRoundtriprender_to(sink) -> full_html()matchesHtmlTemplate.render()for single-variable template with random values. Confirms escaping equivalence._TestRenderSinkRoundtripMultiVar_TestRenderSinkIfCollapseifblock collapses to one dynamic; verify sizes andfull_html()._TestRenderSinkEmptyDynamics_TestRenderSinkInterleavefull_html()matches manual interleave. Uses generated arrays directly (not templates) to test the reconstruction logic independently.Roundtrip generator: Generate random
ascii_printable(0, 50)strings as template variable values. Create anHtmlTemplatewith the variables, render both ways, compare. TheHtmlTemplateensures escaping is exercised.Counterfactual for roundtrip tests: Break the interleaving order in
full_html()(e.g., swap indices), verify the assertion fires. Also verify that HTML special characters (<,&,") in generated values produce different output from a non-escaping template (confirms the test exercises escaping behavior, not just passthrough)._RenderSinkDiff Tests_TestRenderSinkFirstRenderresult()returns_FullRender._TestRenderSinkNoDiff_NoChange._TestRenderSinkPartialDiff_SlotDiffwith correct indices and values._TestRenderSinkAllSlotsDiff_SlotDiff(not_FullRender)._TestRenderSinkStaticsMismatch_FullRender._TestRenderSinkClearclear(), next render returns_FullRender._TestRenderSinkAbandonabandon(), cached state is preserved (next render diffs against pre-abandon state)._TestRenderSinkDiffPropertyPonyCheck generator for
_TestRenderSinkDiffProperty: Generate an array of N slots (3-10). For each slot, generate a "base" string. Then generate a second array where each slot usesGenerators.bool()to decide whether to copy the base value or generate a new random string. Without thebool()control, random strings almost never match (the PonyCheck generator coverage caveat from CLAUDE.md applies), leaving the_NoChangepath undertested.The property asserts: the returned
_SlotDiff.changesarray contains exactly those indices wherebase[i] != modified[i], in order. If all match, the result is_NoChange.Counterfactual for
_TestRenderSinkNoDiff: Change one value between updates, verify_SlotDiffinstead of_NoChange.PonyCheck counterfactual caveat: When checking counterfactuals on property tests, verify the generator actually covers the relevant branch before concluding an assertion is weak. A passing counterfactual might mean the generator rarely produces values reaching the broken branch.
Wire Protocol Tests
_TestEncodeRenderFull"t":"render_full","s"array,"d"array._TestEncodeRenderDiff"t":"render_diff","d"object with string keys._TestEncodeRenderDiffEmpty"d"object.LiveView Default Test
_TestLiveViewRenderPartsDefaultfalse.JS Client Tests
Wire tests (
client/test/wire.test.js):render_full-- correct structure with statics and dynamicsrender_diff-- correct structure with dynamics objectrenderstill works -- regression testrender_fullrender_diffLiveView tests (
client/test/live-view.test.js):render_fullrenders HTML into targetrender_diffpatches only changed dynamicsrender_diffbeforerender_fullis silently ignoredrender_fullrequired)renderworks and clears split stateStep 10: Update Documentation
CLAUDE.md:render_parts()to the Public API section (on LiveView)_RenderSink,_RenderDiff,_FullRender,_SlotDiff,_NoChangeto Internalsrender_full/render_diffto Wire Protocol_render_sink.ponyto File LayoutPackage docstring (
livery/livery.pony): Add a section on split rendering explaining the opt-in mechanism, when to use it, and the typicalrender_partsimplementation pattern.Examples README (
examples/README.md): Update descriptions to mention split rendering.Implementation Order
Steps 2-4 have no compile-time dependencies on each other. Step 5 integrates them all into
_Connection. The JS client work (Step 7) can proceed in parallel with Step 5. Tests (Step 9) span the full process -- each step's tests are written alongside the step, with the full suite run at the end.Files Changed
New files:
livery/_render_sink.pony--_RenderSink,_RenderDiff,_FullRender,_SlotDiff,_NoChangeModified files:
corral.json-- templates 0.3.2livery/live_view.pony-- addrender_parts(), adduse templates = "templates"livery/_wire_protocol.pony--encode_render_full(),encode_render_diff()livery/_connection.pony--_render_sinkfield,_try_split_renderhelper, dual render pathlivery/_test.pony-- all new server testslivery/livery.pony-- package docstring updateclient/src/wire.js-- decode new message typesclient/src/live-view.js-- split state,_applyHtml,_assembleHtml, dual message handlingclient/test/wire.test.js-- new decode testsclient/test/live-view.test.js-- new render testsexamples/counter/main.pony--render_partsoverrideexamples/ticker/main.pony--render_partsoverrideexamples/form/main.pony--render_partsoverrideexamples/todo/main.pony--render_partsoverride with_prepare_valuesexamples/ssr/main.pony--render_partsoverrideexamples/README.md-- update descriptionsCLAUDE.md-- update public API, internals, wire protocol, file layoutUnchanged:
livery/assigns.ponylivery/socket.ponylivery/component_socket.ponylivery/factory.ponylivery/page_renderer.ponylivery/info_receiver.ponylivery/_null_info_receiver.ponylivery/pub_sub.ponylivery/router.ponylivery/listener.ponylivery/_component_registry.ponylivery/_unreachable.ponylivery/live_component.ponyclient/src/socket.jsclient/src/events.jsclient/src/index.jsCo-deployment Assumption
The JS client and Pony server live in the same repository and are deployed together. VERSION is
0.0.0. No wire protocol backward compatibility is needed between different client/server versions.All reactions