Phase 6: Static/Dynamic Splitting — Implementation Plan #28
Closed
SeanTAllen
started this conversation in
Research
Replies: 1 comment
-
|
Closed in favor of #30 |
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
This is the implementation plan for Phase 6 of the livery design. The goal is wire efficiency: send only changed dynamic values instead of full HTML on every state change. Static template parts get sent once per connection, and subsequent renders send only the dynamic slots that changed.
The design document says "This is an internal optimization — the LiveView trait stays the same." The plan interprets that as backward compatible: existing views compile and work unchanged. But it does add a new defaulted method to the trait, so that interpretation needs discussion before implementation starts.
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.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._Connectionactor = fresh state. Server sends statics + all dynamics on every new connection.HtmlTemplate's contextual auto-escaping. The client concatenates pre-escaped strings.render(). The split protocol is WebSocket-only.Design Decision: Partial vs Non-Partial
render()is partial because rendering can fail and the framework must handle it.render_parts()is non-partial, returning(RenderParts | None).Nonemeans both "I don't support split rendering" and "split rendering failed" — the framework's response to both is the same: fall back torender(). This makes the framework simpler but creates an inconsistency between the two render methods.This needs discussion before implementation.
Steps
Step 1: Add
render_splitto the Templates LibraryRepo:
ponylang/templates(separate PR, release as 0.4.0)One new method on
HtmlTemplateandTemplate: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.The implementation is a new
_render_parts_splitthat walks_partsthe same way_render_partsdoes:_Literaltext: append to current static accumulator_PropNode,_Pipe): flush static accumulator, evaluate expression with escaping via_HtmlContextTracker, add result to dynamics array, start new static accumulator_If,_IfNot,_Loop,_Block): flush static accumulator, render entire block to a single string using existing_render_partsrecursion, add as one dynamic slotHTML context tracking feeds static literals through
_HtmlContextTrackerexactly asrenderdoes, so dynamic expressions get correct contextual escaping. Both methods walk the same AST with the same tracker; the difference is only in output collection.Control flow nodes become single opaque slots. When
{{ if flag }}content{{ end }}appears, the inner content becomes part of a dynamic slot value, not a static segment. The statics array is identical regardless of which branch is taken. Includes and inheritance are already resolved at parse time by_ParserCommon, sorender_splitjust walks the final resolved_partsarray.If two
{{ }}blocks have no text between them,render_splitinserts an empty string in the statics array 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()outputStep 2: Update Livery's Templates Dependency
Update
corral.jsonto templates 0.4.0.Step 3: Add
RenderPartsPublic TypeNew file:
livery/render_parts.ponyThe constructor is non-partial because
render_split()guarantees the interleaving invariant by construction.Tests:
full_html()of random valid parts equals manual interleavefull_htmlStep 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 can't 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:
update(parts: RenderParts): _RenderDiffcompares statics by identity (is), dynamics by element-wise string equality. Returns_FullRenderon first render or template change,_SlotDifffor partial changes,_NoChangeif all slots match.Types:
_RenderDiff is (_FullRender | _SlotDiff | _NoChange)Statics identity comparison works because
HtmlTemplatecaches statics as avalfield — same template instance, same reference. If a user switches templates mid-session, identity differs, triggering a full render. A false positive (different instance, same content) just causes a harmless redundant full render.Defensive: if
prev_dynamics.size() != new_dynamics.size(), return_FullRender.Tests:
_FullRender_NoChange_SlotDiffwith correct indices_FullRenderStep 6: Update Wire Protocol
File:
livery/_wire_protocol.ponyTwo new message types alongside the existing
encode_render: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.Tests:
encode_render_fullJSON structureencode_render_diffJSON with string keysdobjectStep 7: Update
_Connectionfor Split RenderingFile:
livery/_connection.ponyAdd
let _render_state: _RenderState ref = _RenderState.Both
on_openand_maybe_rerenderget a dual path:When components change,
populate_component_htmlupdates the assigns. The view'srender_partsproduces dynamics that include the component HTML as an unescaped slot value._RenderStatedetects the changed slot via string comparison. No changes to_ComponentRegistryneeded.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.jsExisting
"render"case preserved for the full-HTML fallback.Tests: decode render_full, render_diff, legacy render regression, empty dynamics, multiple changed slots.
Step 10: Update JS Client LiveView
File:
client/src/live-view.jsAdd
_staticsand_dynamicsstate. Extract morphdom/innerHTML into_applyHtml(html). Add_assembleHtml(statics, dynamics).Clear
_staticsand_dynamicson reconnect. Bounds-check diff indices to prevent malformed messages from corrupting the array.Tests: render_full renders, render_diff patches, diff before full ignored, reconnect resets, legacy render works, SSR + split, out-of-bounds diff ignored.
Step 11: Update Examples
Views with derived render-time computation (like the todo example building
items_htmlfrom component HTML) use a_prepare_valueshelper to share logic betweenrenderandrender_parts:All five examples (counter, ticker, form, todo, ssr) add
render_partsoverrides. Updateexamples/README.md.Step 12: Tests
Build/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 in
livery/_test.pony:_RenderPartsFullHtml_RenderPartsEmptyDynamics_RenderPartsInterleave_TestRenderStateFirstRender_TestRenderStateNoDiff_TestRenderStatePartialDiff_TestRenderStateStaticsChanged_RenderStateDiffProperty_TestEncodeRenderFull_TestEncodeRenderDiff_TestEncodeRenderDiffEmpty_TestLiveViewRenderPartsDefaultJS tests: decode render_full, decode render_diff, legacy render regression, render_full renders, render_diff patches, diff before full ignored, reconnect resets, out-of-bounds ignored, legacy render works, SSR + split.
Step 13: Update Documentation
CLAUDE.md: add
RenderPartsandrender_parts()to public API,_RenderStateand friends to internals,render_full/render_diffto wire protocol, new files to layout, update conventions for PonyCheck coverage.Package docstring (
livery/livery.pony): add section on split rendering.Examples README: update descriptions.
Implementation Order
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_state, dual render pathlivery/_test.pony— all new testslivery/livery.pony— package docstringclient/src/wire.js— decode new message typesclient/src/live-view.js— statics/dynamics state, assembly, dual message handlingclient/test/wire.test.js— new decode testsclient/test/live-view.test.js— new render testsexamples/counter/main.pony— render_parts overrideexamples/ticker/main.pony— render_parts overrideexamples/form/main.pony— render_parts overrideexamples/todo/main.pony— render_parts override with _prepare_valuesexamples/ssr/main.pony— render_parts overrideexamples/README.mdCLAUDE.mdUnchanged:
assigns.pony,socket.pony,component_socket.pony,factory.pony,page_renderer.pony,info_receiver.pony,_null_info_receiver.pony,pub_sub.pony,router.pony,listener.pony,_component_registry.pony,_unreachable.pony,live_component.pony,client/src/socket.js,client/src/events.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.Open Questions
"LiveView trait stays the same" — how strict? Adding
render_parts()with a defaultNoneis backward compatible (existing views compile and work unchanged), but it adds a new method. If this violates the intent, the alternative is a registration-based approach likeSocket.register_split_template()inmount().Partial vs non-partial
render_parts? The plan proposes non-partial (returns None on error). This is simpler for the framework but inconsistent withrender()being partial. Shouldrender_partsbe partial too?Performance of string comparison for large opaque slots. Control flow blocks that render many items (a loop with 100 entries) produce one big dynamic string that gets compared every cycle. A future optimization could track assign-to-slot dependencies.
Render method duplication. Views need both
render()(for PageRenderer) andrender_parts()(for WebSocket). The_prepare_valueshelper pattern mitigates this for views with derived values, but both methods still exist.Beta Was this translation helpful? Give feedback.
All reactions