Phase 4: Server-Rendered First Paint #18
Closed
SeanTAllen
started this conversation in
Research
Replies: 1 comment
-
|
Implemented in #20. |
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.
-
Phase 4: Server-Rendered First Paint
Implementation plan for Phase 4 of livery, derived from the design in #3.
Scope
Eliminates the empty-page flash on initial load. The HTTP server renders the LiveView to HTML at request time; the browser receives a fully populated page. When the JS client opens the WebSocket, the server mounts a fresh view (producing identical initial HTML), and morphdom silently takes over the pre-rendered DOM.
Deliverables:
Socket.connected()— distinguishes HTTP render from live WebSocketPageRenderer— primitive that creates a temporary LiveView, mounts it, renders to HTMLPageRenderError— error union for factory/render failuresDesign Decisions
Connection tokens are deferred
The design lists "embedded connection token for WebSocket handshake" as a Phase 4 feature. After analysis, tokens serve no functional purpose without authentication or sessions:
mount()from scratch, producing identical initial state. There's no state to transfer.ws://host:port/path, which is sufficient to mount the correct LiveView.Tokens should be added in a future phase alongside authentication/sessions, where they serve a real purpose. Adding them now would be ceremony without function.
PageRenderer takes a Factory, not Routes
PageRenderer.render(factory)rather thanPageRenderer.render(routes, path).The HTTP router (hobby) already matched the request to a handler before PageRenderer is called. The handler knows which Factory to use. Having PageRenderer re-match against livery's Routes would duplicate routing and require extracting the request path from the HTTP framework — an unnecessary coupling.
The Factory-based API is more composable: the caller handles routing (hobby does this naturally), and PageRenderer handles mount + render.
Socket: optional PubSub, connected flag
Rather than creating dummy PubSub actors for the render path, Socket's
_pub_subfield becomes(PubSub tag | None). Subscribe/unsubscribe match on the field and no-op when None. This avoids creating throwaway actors and makes the disconnected state explicit in the type._selfstays asInfoReceiver tag(always set) to keepself()non-partial. In the render path, a lightweight_NullInfoReceiveractor provides a valid but inert reference.push_eventcontinues to append to the pending events array unconditionally — in the render path the array is never flushed and gets GC'd with the rest of the temporary state. No conditional needed.Hobby dependency is blocked by a lori version conflict
Hobby 0.2.0 depends on stallion 0.4.0, which requires lori 0.10.0. Mare 0.1.1 (livery's existing dependency) requires lori 0.8.5. Corral resolves each package to a single version, so adding hobby causes a conflict.
Options considered:
(A) Wait for mare to update to lori 0.10.0. Requires a PR to mare, a new mare release, and an update to livery's deps. This is the right long-term fix but blocks Phase 4 on work in another repo.
(B) Demonstrate SSR without hobby. The example uses a hand-written
index.htmlwith pre-rendered content. The library providesPageRendererand the JS client supports pre-rendered takeover — both are fully implemented and tested — but the example doesn't dynamically generate the page via HTTP.(C) Update mare ourselves as part of this work. Adds scope to the current phase.
Decision: Option B. The SSR example uses a static
index.htmlwith pre-rendered counter HTML already embedded in thelv-rootdiv. This demonstrates the end-to-end UX (pre-rendered first paint → WebSocket takeover → morphdom) while keeping the library API complete (PageRenderer,Socket.connected()). The package docstring documents the full pattern including hobby integration, so users understand how to wire it together with a real HTTP server. When mare is updated, a follow-up adds hobby and converts the example to dynamic rendering.A GitHub issue should be filed during implementation to track the hobby integration as a follow-up.
New example rather than updating counter
The design says "update existing counter." However, the current counter example is the simplest introduction to livery — changing it to require an HTTP server raises the barrier to entry. A separate
ssrexample preserves the simple counter and adds a focused SSR demonstration. Users compare the two to see what server-rendered first paint adds.Dependencies
No new external dependencies. The hobby integration is deferred due to the lori version conflict (see design decision above).
Files
Create
livery/_null_info_receiver.pony— No-op actor satisfyingInfoReceiverfor disconnected sockets:livery/page_renderer.pony—PageRendererprimitive andPageRenderErrortype:examples/ssr/main.pony— Counter with server-rendered first paint:Imports:
use "templates",use "json",use lori = "lori",use "../../livery".Same
CounterViewas the counter example — mount sets count to "0", handle_event increments/decrements, render uses HtmlTemplate.Maincreates routes and starts the WebSocket server on port 8084 (counter=8081, ticker=8082, form=8083). Identical to the counter example's Main, just a different port.examples/ssr/index.html— HTML shell with pre-rendered content:Unlike the other examples where
<div id="lv-root"></div>is empty, this file has the counter's initial render already embedded:The user sees the counter immediately on page load. When the WebSocket connects, the server mounts the same view (producing identical HTML), and morphdom silently takes over — no flash, no visible change.
This demonstrates the end-to-end UX. In production,
PageRenderer.render(factory)would generate this HTML dynamically in an HTTP handler (e.g., hobby). The lori version conflict currently prevents adding hobby as a dependency (see design decisions).Modify
livery/socket.pony— Add connected state and render-path constructor:let _connected: Bool_pub_subfield type fromPubSub tagto(PubSub tag | None)createconstructor: set_connected = true, parameter type staysPubSub tag(widened to the union on assignment)new _for_render(assigns, pending_events)— sets_connected = false,_pub_sub = None, creates_NullInfoReceiverfor_selffun box connected(): Bool— returns_connected, with docstring explaining the HTTP render vs WebSocket distinctionsubscribeandunsubscribeto match on_pub_sub, no-op when None:The
createconstructor signature is unchanged —_Connectioncall sites don't need updating.client/src/live-view.js— Detect pre-rendered content:Change the render case in
_handleMessage:When the target already has a child element (pre-rendered HTML from the HTTP response), the first WebSocket render uses morphdom instead of innerHTML. Since the pre-rendered HTML matches the initial WebSocket render (both mount from scratch with the same initial state), morphdom makes no visible changes — it silently takes over the DOM.
When the target is empty (no SSR), behavior is unchanged: innerHTML inserts the content.
livery/live_view.pony— Update trait docstrings:mount: Change "Called when the WebSocket connection is established" to reflect that mount is also called during HTTP rendering with a disconnected socket. Mention checkingsocket.connected()to distinguish the two contexts.render: Add note about single root element requirement — the returned HTML must have a single root element (e.g., wrapped in a<div>) for morphdom to work correctly.livery/livery.pony— Update package docstring:Add a "Server-Rendered First Paint" section documenting:
PageRendererdoes and when to use itSocket.connected()works and when to check itString val | PageRenderFactoryFailed | PageRenderFailed)livery/_test.pony— Add tests (see Tests section).client/test/live-view.test.js— Add tests (see Tests section).examples/README.md— Add ssr example entry. Update the opening paragraph (which says all examples include anindex.html) to note that the ssr example'sindex.htmlcontains pre-rendered content. Add the ssr example to the running instructions table (port 8084).CLAUDE.md— Update the file layout to addssr/under examples andpage_renderer.pony/_null_info_receiver.ponyunderlivery/. AddPageRenderer,PageRenderFactoryFailed,PageRenderFailedto the Public API section. Add_NullInfoReceiverto the Internal section. Update theSocketentry in Public API to mentionconnected().Tests
Server-side (
livery/_test.pony)PageRenderer tests (example-based):
_TestPageRendererSuccess— factory that creates a valid view (minimal test view with a fixed template) → returns rendered HTML. Verify the returned string contains expected content._TestPageRendererFactoryFailed— factory that always errors → returnsPageRenderFactoryFailed._TestPageRendererRenderFailed— factory that creates a view whoserenderalways errors → returnsPageRenderFailed.These tests use test-only LiveView implementations (private test classes with
\nodoc\). A_RenderTestViewthat renders a fixed template, and a_FailRenderViewwhose render always errors.Socket.connected() tests:
_TestSocketConnectedTrue— Socket created via normalcreateconstructor →connected()returnstrue._TestSocketConnectedFalse— Socket created via_for_render→connected()returnsfalse._TestSocketDisconnectedSubscribeNoop— callsubscribe("topic")andunsubscribe("topic")on a disconnected socket → no crash (verifies the match-on-None path works for both methods).Client-side (
client/test/live-view.test.js)Pre-rendered content preserved: target has a child element before connect → first render message uses morphdom (the existing child node's identity is preserved, not replaced by innerHTML).
Empty target unchanged: target has no children → first render uses innerHTML. Verifies existing behavior is not broken.
Subsequent renders after takeover: after pre-rendered takeover, a second render message still uses morphdom normally.
Build and Verify
Server
make test ssl=openssl_3.0.xBuilds unit tests + all examples (including ssr). The Makefile auto-discovers example directories.
Client
Files summary
livery/_null_info_receiver.ponylivery/page_renderer.ponylivery/socket.ponylivery/live_view.ponylivery/livery.ponylivery/_test.ponyclient/src/live-view.jsclient/test/live-view.test.jsexamples/ssr/main.ponyexamples/ssr/index.htmlexamples/README.mdCLAUDE.mdBeta Was this translation helpful? Give feedback.
All reactions