Phase 6: Static/Dynamic Splitting — Refined Implementation Plan #29
Closed
SeanTAllen
started this conversation in
Research
Replies: 1 comment
-
|
Implementation PR: #31 |
Beta Was this translation helpful? Give feedback.
0 replies
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
Predecessor: #28
This is the refined implementation plan for Phase 6 of the livery design, replacing the preliminary plan in Discussion #28. All four open questions from #28 are resolved. The plan includes concrete Pony code with capability issues addressed, a performance analysis, and a complete test plan.
How It Works
Templates already parse into static text and dynamic expressions internally. Phase 6 exposes that split through a new
render_split()method onHtmlTemplate, which returns(statics, dynamics)arrays instead of a concatenated string. The statics are fixed at parse time; the dynamics change on each render. The framework tracks previous dynamics per connection and sends only the slots that changed.Views opt in by overriding a new
render_parts()method onLiveView. The default returnsNone, which tells the framework to use the existing full-HTML path. Views that override it get the split protocol. Views that don't change nothing.Resolved Open Questions
Q1: Adding
render_parts()to the LiveView TraitDecision: Add
fun box render_parts(assigns: Assigns box): (RenderParts | None) => Noneas a defaulted method onLiveView.This is backward compatible -- existing views compile and work unchanged. The design document's statement that "the LiveView trait stays the same" is interpreted as "existing views don't break," not "the trait gains zero new methods." The
handle_infoprecedent supports this: it was added as a defaulted no-op method, same pattern.Alternatives considered and rejected:
SplitLiveViewtrait: Pony cannot downcast fromLiveView refto a subtype, so_Connectioncouldn't callrender_partson aLiveView refit already holds.Socket.register_split_template()in mount: Mixes rendering concerns into lifecycle setup.Q2: Partial vs Non-Partial
Decision: Non-partial, returning
(RenderParts | None).Noneencodes both "not supported" and "rendering failed." The framework's response to both is identical: fall back torender(). Making it partial would require two error paths for no benefit. The inconsistency withrender()being partial is acceptable because the semantics differ --render()is the single source of HTML and must distinguish success from failure, whilerender_parts()is an optional optimization with an automatic fallback.Q3: String Comparison Performance
Decision: Accept element-wise string comparison for V1. The comparison cost is marginal relative to the render cost itself (template evaluation, escaping, string building). For typical payloads, the comparison is negligible -- counter: 1 slot, 1-4 bytes; form: 7 slots, each 0-50 bytes; todo with 100 items: 1 large slot but comparison adds roughly 10-20% overhead to the render cycle, which is acceptable. The correct long-term optimization (assign-level dependency tracking to skip rendering entirely for unchanged slots) requires template static analysis and is deferred.
Alternatives evaluated and rejected for V1:
Q4: Render Method Duplication
Decision: The
_prepare_valueshelper pattern is sufficient. Views with derived template values (e.g., todo buildingitems_htmlfrom component HTML) extract value preparation into a shared helper used by bothrender()andrender_parts(). Both methods still exist, but the duplicated logic is minimal -- each is a thin wrapper around the shared helper.Invariants
statics.size() == dynamics.size() + 1always holds.statics[0] + dynamics[0] + statics[1] + ... + statics[N].assemble(render_split(values)) == render(values)for all values. This is the core correctness property and the most important test (belongs in the templates repo)._Connectionactor = fresh_RenderState. 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 the current full-HTML message due to the statics array overhead. Break-even occurs after just 2 renders for any view. Split rendering wins for any view that gets more than 2 renders per connection, which is every interactive view.
Memory Per Connection
_RenderStatestores: one_staticspointer (8 bytes), one_prev_dynamicsarray of NString valreferences (~8N bytes + string data). Counter: trivial. Form (7 slots): ~56 bytes of pointers. A 50-slot template: ~400 bytes of pointers. The dynamics array fromrender_splitisvaland assigned directly to_prev_dynamics-- no copying. The old array becomes garbage via standard Pony GC.CPU Cost of Diff Computation
is): O(1) single pointer comparison. Works becauseHtmlTemplatecaches statics as avalfield -- same template instance, same reference. Two differentHtmlTemplateinstances from identical source have different statics allocations --isreturns false, triggering a harmless full render. No false negative risk. Thevalreference survives GC compaction (Pony GC uses identity tags that are stable).String valprovides an early-out before byte comparison.full_html()AllocationPre-allocates a result string to total size (one allocation + N+1 copies). Called on every non-
_NoChangerender to update_last_htmlfor error recovery. Not called on_NoChangesince_last_htmlis already current.JSON Encoding Overhead
render_diffstring keys add ~2 bytes per changed slot (quotes). Negligible for 1-3 changed slots.render_fullJSON escaping adds ~5-10% to statics size -- paid once per connection.json.JsonArray.push()is a persistent vector O(log N) per push; for N < 100, immaterial.Steps
Step 1: Add
render_splitto the Templates LibraryRepo:
ponylang/templates(separate PR, release as 0.4.0)One new method on
HtmlTemplate:Returns
(statics, dynamics). For N dynamic slots, statics has N+1 entries and dynamics has N entries.Statics are precomputed at parse time and cached as
let _statics: Array[String val] valon the template. Subsequent calls return the same cachedvalreference, enabling cheap identity comparison (is) by callers.HtmlTemplateisclass val-- fields are immutable after construction -- so statics must be computed eagerly in the constructor, not lazily cached. The cost is one walk of the_partstree at parse time, O(template size), negligible compared to parsing.The implementation is a new
_render_parts_splitthat walks_partsthe same way_render_partsdoes, using_HtmlContextTrackerfor escaping context:_Literaltext: feed tracker (for context tracking), skip collection (pre-computed in statics)_PropNode,_Pipe): evaluate expression with escaping via tracker context, append result to dynamics array_If,_IfNot,_Loop,_Block): render entire block to a single string using existing_render_partsrecursion, add as one dynamic slot. Feed the rendered string through the tracker so subsequent literal/dynamic nodes get correct escaping context._statics; only the dynamics array is newly built.If two
{{ }}blocks have no text between them, the pre-computed statics array contains an empty string between them to maintain the interleaving invariant.Edge cases:
[""], dynamics =[]["the text"], dynamics =[]["", ""], dynamics =[value]Tests (in templates repo):
statics.size() == dynamics.size() + 1for various templatesassemble(statics, dynamics) == render(values)for random valuesrender()outputis)Step 2: Update Livery's Templates Dependency
Update
corral.jsonto templates 0.4.0. Runmake cleanbefore rebuilding -- when a dependency drops or renames files between versions,corral fetchmay leave stale files from the old checkout in_corral/.Step 3: Add
RenderPartsPublic TypeNew file:
livery/render_parts.ponyCapability note: The
full_html()method has aboxreceiver (default forfunon avalclass). Inside therecover valblock,thisis not sendable, sostaticsanddynamicsfields cannot be accessed directly. The fields are read into localvalvariables (sandd) before the recover block. Both areArray[String val] val(sendable), so the locals are accessible inside recover.The constructor is non-partial because
render_split()guarantees the interleaving invariant by construction.Step 4: Add
render_partsto LiveViewFile:
livery/live_view.ponyLiveComponentdoes not getrender_partsin this phase. Component output is flattened to a string for injection into the parent's template values viacomponent_html(). The framework cannot use structured component output without restructuring the component registry and wire format. Component-level split rendering is deferred.Step 5: Add
_RenderStateInternal TypeNew file:
livery/_render_state.ponyPer-connection state for diff computation:
Capability note: Inside the
recover valblock,prev_dandnew_dynamicsare bothArray[String val] val(sendable), so they are accessible. The changes array is built inside recover and becomesvalon exit.Step 6: Update Wire Protocol
File:
livery/_wire_protocol.ponyTwo new encoding functions alongside the existing
encode_render:Wire format:
render_fullsends statics + all dynamics (first render or template change).render_diffsends only changed slot indices as string keys mapping to new values. The existingencode_render(html)is preserved for the full-HTML fallback path. Therender_diffstring-keys-on-object format matches Phoenix LiveView's approach.Step 7: Update
_Connectionfor Split RenderingFile:
livery/_connection.ponyAdd
let _render_state: _RenderState ref = _RenderStateas a field.Both
on_openand_maybe_rerenderget a dual render path. The initial render inon_opentriesrender_partsfirst:The
_maybe_rerendermethod follows the same pattern, with one addition -- whenrender_partsreturnsNoneafter previously returningRenderParts, the server calls_render_state.clear()to discard stale state:Fallback transition: When the server sends a plain
rendermessage (notrender_fullorrender_diff), the client clears its split state. This handles the case where a view switches from split to full rendering mid-session.Step 8: No Changes to Component Registry or PageRenderer
Components render to full HTML strings via
render(). A component's 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 9: Update JS Client Wire Module
File:
client/src/wire.jsAdd two new cases to
decodeServerMessage:The existing
"render"case is preserved for the full-HTML fallback path.Step 10: Update JS Client LiveView
File:
client/src/live-view.jsAdd
_staticsand_dynamicsstate fields (initiallynull). Extract morphdom/innerHTML logic into_applyHtml(html). Add_assembleHtml(statics, dynamics).Key behaviors:
_staticsand_dynamicsare cleared inonOpen.render_diffbeforerender_fullis silently dropped. The guardif (!this._statics || !this._dynamics) breakprevents corrupted state.i >= 0 && i < this._dynamics.lengthprevents malformed messages from corrupting the array.renderclears split state. If the server falls back to full-HTML, the client discards stale statics/dynamics.Step 11: Update Examples
All five examples add
render_partsoverrides. Views with derived values use the_prepare_valueshelper pattern.Counter example (
examples/counter/main.pony) -- straightforward override:Todo example (
examples/todo/main.pony) -- shared value preparation:The ticker, form, and ssr examples follow the same pattern as counter (direct template override) or todo (shared helper), depending on whether they have derived values.
Update
examples/README.mdto mention that examples demonstrate split rendering.Step 12: 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.Server Tests (
livery/_test.pony)RenderParts tests:
_TestRenderPartsFullHtml["<div>", "</div>"]+ dynamics["42"]produce"<div>42</div>"_TestRenderPartsEmptyDynamics["hello"]+ dynamics[]produce"hello"_TestRenderPartsInterleavefull_html()matches manual interleave for random inputsPonyCheck generator for
_TestRenderPartsInterleave: Generate a random number of dynamic slots (1-20 viaGenerators.usize(1, 20)), then for each: generate random static strings and dynamic strings viaGenerators.ascii_printable(0, 50). Ensurestatics.size() == dynamics.size() + 1by construction (generate N dynamics and N+1 statics).Counterfactual: break the interleaving order (e.g., swap statics[0] and statics[1]), verify assertion fires.
_RenderState tests:
_TestRenderStateFirstRender_FullRender_TestRenderStateNoDiff_NoChange_TestRenderStatePartialDiff_SlotDiffwith correct indices_TestRenderStateStaticsChanged_FullRender_TestRenderStateSizeMismatch_FullRender_TestRenderStateClearclear(), next update returns_FullRender_TestRenderStateDiffPropertyPonyCheck generator for
_TestRenderStateDiffProperty: 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. This ensures both matching and non-matching slots appear regularly -- without thebool()control, random string generation almost never produces matching slots (the generator coverage caveat from CLAUDE.md applies), making 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. If any base vs modified pair differs, it is_SlotDiff.Counterfactual caveat: When checking counterfactuals on PonyCheck 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_TestEncodeRenderDiffEmptydobjectLiveView default test:
_TestLiveViewRenderPartsDefaultNoneJS 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 (clears split state)Step 13: Update Documentation
CLAUDE.md:RenderPartsandrender_parts()to the Public API section_RenderState,_RenderDiff,_FullRender,_SlotDiff,_NoChangeto Internalsrender_full/render_diffto Wire Protocolrender_parts.ponyand_render_state.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 3-6 have no compile-time dependencies on each other and can be written in any order. Step 7 integrates them all into
_Connection. The JS client work (Steps 9-10) has no Pony dependency and can proceed in parallel with Step 7. Tests (Step 12) 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_parts.pony--RenderPartspublic classlivery/_render_state.pony--_RenderState,_RenderDiff,_FullRender,_SlotDiff,_NoChangeModified files:
corral.json-- templates 0.4.0livery/live_view.pony-- addrender_parts()livery/_wire_protocol.pony--encode_render_full(),encode_render_diff()livery/_connection.pony--_render_statefield, dual render path inon_openand_maybe_rerenderlivery/_test.pony-- all new server testslivery/livery.pony-- package docstring updateclient/src/wire.js-- decode new message typesclient/src/live-view.js--_statics/_dynamicsstate,_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.Remaining Uncertainties
These are genuinely open questions that need empirical verification during implementation:
Templates
render_splitescaping equivalence. The implementation must produce identical escaping torender(). The_HtmlContextTrackerprocesses HTML text character-by-character so rendered control-flow block output should be handled correctly, but this needs verification -- particularly for nested control flow within loops.Templates library PR acceptance and timeline. This is the critical path dependency. The templates library is in the same GitHub organization, but requires a separate PR, review, and release as 0.4.0.
Statics caching mechanism in
HtmlTemplate. Statics must be computed eagerly in the constructor sinceHtmlTemplateisclass val. This requires walking_partsat construction time to extract the static skeleton -- the exact implementation depends on the_Parttype hierarchy (_Literal,_PropNode,_Pipe,_If,_IfNot,_Loop,_Block), which is internal to the templates library.Synthesis Rationale
This plan was produced by an ensemble of three agents with different attention focuses (API design, testing strategy, performance), each independently reviewing the predecessor plan (#28) and the current codebase. Key contributions by focus:
API agent: Clean resolution of all four open questions with alternatives-considered reasoning. Identified
_RenderState.clear()for fallback transitions that the other agents missed. Caught thefull_html()recover-val capability issue.Tests agent:
Generators.bool()per-slot strategy for the_TestRenderStateDiffPropertyPonyCheck generator -- without this, random strings almost never match, leaving the_NoChangepath undertested. Identified the counterfactual caveat for PonyCheck tests (generator coverage vs. weak assertions).Performance agent: Concrete bandwidth analysis showing break-even after 2 renders. Memory and CPU cost analysis confirming negligible overhead. Resolved the statics identity comparison reliability question.
full_html()pre-allocation pattern.Emergent from combination: The fallback transition story (server-side
_render_state.clear()+ client-side clearing on legacyrendermessage) emerged from combining the API agent'sclear()method with the tests agent's client-side state clearing observation. Therecover valcapability analysis was verified from three independent angles.Beta Was this translation helpful? Give feedback.
All reactions