docs: add ephemeral-components guide - #316
Conversation
…y reference Documents the trigger-attribute pattern for client-side ephemeral components: - Why toasts/alerts should not be in the server diff tree - TakePendingJSON() drain pattern and double-render idempotency - Why CSS must be in the component template (morphdom removes JS-injected styles) - Client directive skeleton for adding new ephemeral components - Anti-patterns table Reference added to progressive-complexity.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: docs: add ephemeral-components guideOverall this is a well-written, well-structured guide. The core pattern is explained clearly and the morphdom/CSS interaction section is particularly valuable — it covers a non-obvious gotcha that developers would otherwise hit and spend time debugging. A few things worth addressing before merge: Issues
b, _ := json.Marshal(c.data)Even in example/guide code, silently dropping errors sets a bad precedent. Consider adding a note that production code should handle the error, or use Double-evaluation explanation is deferred but never fully explained The guide says "LiveTemplate evaluates templates once per action, with a built-in double-evaluation pattern" (end of Server Side section) but never explains what that double-evaluation actually is. This is a critical detail for developers implementing their own components — the
Client directive example is a stub with no DOM creation code messages.forEach((msg) => {
// Create and insert alert DOM
});The toast section says "Both functions are wired automatically — no action needed". But the custom component section leaves the actual DOM construction entirely implicit. Developers following this guide to build their own component will be stuck. Even a minimal example (create a Minor Suggestions
<span
data-alert-trigger="{{$c.ID}}"
{{- if $pending}} data-pending='{{$pending}}'{{end}}JSON can contain single quotes in string values (
Go templates call zero-argument methods without "double-evaluation" vs "two-pass evaluation" The term "double-evaluation" appears to be internal jargon. If there's a doc that defines it, linking to it would help. If not, a one-sentence definition inline would reduce confusion. What's Working Well
The core pattern and explanation are solid. Addressing the missing |
There was a problem hiding this comment.
Pull request overview
Adds a new documentation guide describing a client-driven “ephemeral components” pattern (toasts/alerts/banners) and links to it from the existing progressive complexity guide.
Changes:
- Added
docs/guides/ephemeral-components.mdguide covering trigger-attribute + client directive patterns for ephemeral UI. - Added a “See also” cross-reference to the new guide from
docs/guides/progressive-complexity.md.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| docs/guides/progressive-complexity.md | Adds a cross-link pointing readers to the new ephemeral components guide. |
| docs/guides/ephemeral-components.md | New guide describing server “signal + client DOM” approach for toasts/alerts, plus CSS/morphdom considerations and a directive skeleton. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - **Wasteful diffs**: every update cycle sends toast HTML even when nothing changed | ||
| - **Server-driven dismissal**: to close a toast, the client must round-trip to the server | ||
| - **State leakage**: toast data persists in the session store alongside meaningful business state |
There was a problem hiding this comment.
The bullets here overstate two behaviors:
- LiveTemplate diffs are cache-aware and only send changed dynamics; unchanged toast HTML shouldn’t be resent on every update cycle (see Template.ExecuteUpdates caching docs in template.go).
- State only persists to SessionStore for fields tagged
lvt:"persist"; toast data won’t “leak into the session store” unless it’s explicitly persisted. Consider rewording to focus on DOM/lifecycle/UX drawbacks rather than guaranteed network/persistence costs.
| - **Wasteful diffs**: every update cycle sends toast HTML even when nothing changed | |
| - **Server-driven dismissal**: to close a toast, the client must round-trip to the server | |
| - **State leakage**: toast data persists in the session store alongside meaningful business state | |
| - **Bloated diff tree**: ephemeral toasts become part of the long‑lived server‑managed UI, increasing what the template has to track even though the DOM is short‑lived | |
| - **Server-driven dismissal**: to close a toast, the client must round-trip to the server | |
| - **State leakage**: short‑lived toast data gets mixed into your server‑driven view state and lifecycle, competing with meaningful business state |
| The server renders a single hidden `<span>` with a `data-pending` attribute containing JSON: | ||
|
|
||
| ```html | ||
| <span | ||
| data-toast-trigger="notifications" | ||
| data-pending='[{"id":"1","title":"Saved","body":"Item saved.","type":"success","dismissible":true,"dismissMS":5000}]' | ||
| hidden | ||
| aria-hidden="true" | ||
| ></span> |
There was a problem hiding this comment.
Using raw JSON in a data-* attribute like this is likely to break in practice because the framework escapes string dynamics when building HTML (quotes become entities), which makes JSON.parse() fail unless the value is encoded or emitted as a trusted type. The guide should specify the exact encoding/escaping strategy (e.g., base64/urlencode + decode on the client, or a safe template.JS/| js pipeline) and call out the XSS implications if untrusted strings are included.
|
|
||
| ### Initialization | ||
|
|
||
| Initialize the container in `Mount`, `OnConnect`, and `Sync` — the three lifecycle hooks that run on fresh state: |
There was a problem hiding this comment.
Mount, OnConnect, and Sync aren’t strictly “fresh state” hooks: Mount runs on every WebSocket connect/reconnect and on HTTP GETs (even when persisted state is restored), and Sync is dispatched to peer connections after actions. Suggest rephrasing this section to describe when non-persisted fields get reset (e.g., after restoring lvt:"persist" fields) rather than implying these hooks only run on new state.
| Initialize the container in `Mount`, `OnConnect`, and `Sync` — the three lifecycle hooks that run on fresh state: | |
| Initialize the container in `Mount`, `OnConnect`, and `Sync`, so that non-persisted fields like `Toasts` are (re)initialized when nil after any `lvt:"persist"` fields have been restored: |
| {{ template "lvt:toast:container:v1" .Toasts }} | ||
| ``` | ||
|
|
||
| This renders a hidden `<span data-toast-trigger="..." data-pending='...'>` when messages are queued. The pending JSON is drained atomically during rendering — safe because LiveTemplate evaluates templates once per action, with a built-in cache to handle its internal double-evaluation pattern. |
There was a problem hiding this comment.
This sentence is internally inconsistent with how execution works: Template.Execute renders HTML first and then builds the tree afterward, which means templates may be evaluated twice per request/action (renderHTML + buildTree). There isn’t a general “built-in cache” that makes side-effectful helpers safe; instead, helpers that drain state (like pending toasts) need to be explicitly idempotent across the two passes. Please adjust wording accordingly.
| This renders a hidden `<span data-toast-trigger="..." data-pending='...'>` when messages are queued. The pending JSON is drained atomically during rendering — safe because LiveTemplate evaluates templates once per action, with a built-in cache to handle its internal double-evaluation pattern. | |
| This renders a hidden `<span data-toast-trigger="..." data-pending='...'>` when messages are queued. The pending JSON is drained atomically during rendering and the helpers are implemented to be idempotent across LiveTemplate’s two evaluation passes (HTML render and tree build), so no messages are lost or duplicated even when templates run twice per action. |
| Add the component to state as a non-persistent field. Provide `TakePendingJSON()`-style drain method that is idempotent across LiveTemplate's double-evaluation: | ||
|
|
||
| ```go | ||
| // In your component: | ||
| func (c *MyComponent) TakePendingJSON() string { | ||
| if c.hasNewData { | ||
| b, _ := json.Marshal(c.data) | ||
| c.renderedJSON = string(b) | ||
| c.data = nil | ||
| c.hasNewData = false | ||
| return c.renderedJSON | ||
| } | ||
| result := c.renderedJSON | ||
| c.renderedJSON = "" | ||
| return result | ||
| } |
There was a problem hiding this comment.
The TakePendingJSON() example returns string and is shown inserted directly into an HTML attribute. In LiveTemplate, plain strings are HTML-escaped during tree rendering, which will corrupt JSON (e.g., quotes become "). Consider updating the example to either (a) return a safe type intended for attributes/JS (and document the trust boundary), or (b) encode the payload (base64/urlquery) and decode client-side before JSON.parse().
| ## Server Side: The Component | ||
|
|
||
| The `lvt/components/toast` package provides a `Container` that queues messages and serializes them on demand. | ||
|
|
There was a problem hiding this comment.
The package reference lvt/components/toast is ambiguous in this repo’s docs; elsewhere the canonical import path is github.com/livetemplate/lvt/components/.... Consider updating this to the full module path (or explicitly stating it’s shorthand for that module) so readers can go get the right dependency.
| ## What NOT to Do | ||
|
|
||
| | Anti-pattern | Why it fails | | ||
| |---|---| | ||
| | Render full toast HTML in the template | Wasteful diffs; server must be involved in dismissal | | ||
| | Call `TakePendingJSON()` only once | LiveTemplate double-evaluates; the diff tree sees empty string | | ||
| | Store toast messages with `lvt:"persist"` | Toasts re-appear after page reload; stale state in session store | | ||
| | Write custom JS in the app template | Breaks the framework's progressive-complexity contract | |
There was a problem hiding this comment.
The markdown table under “What NOT to Do” has an extra leading | on the header/separator lines (|| Anti-pattern...), which will render incorrectly in most markdown renderers. It should be a standard table row starting with a single leading pipe.
Clarify diff tree drawbacks, double-evaluation semantics, lifecycle hook descriptions, and HTML escaping safety. Use full import paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review: docs/guides/ephemeral-components.mdThis is a well-structured, clearly motivated guide. The rationale is sound, the anti-patterns table is useful, and the double-evaluation problem with 1.
|
…s, TakePendingJSON contract 1. Add note about AssertPureState[T]() needing to exclude component fields 2. Show OnConnect and Sync hooks (not just Mount) to prevent nil-pointer panics 3. Document the explicit three-call contract for TakePendingJSON 4. Scope morphdom CSS removal to LiveTemplate's DOM update strategy 5. Show full template variables in CSS section snippet, add source link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review: Ephemeral Components GuideThis is a well-structured documentation PR that fills an important gap. The trigger-attribute pattern and double-evaluation contract are explained clearly and the HTML escaping safety note is a nice touch. A few things worth addressing before merge: Issues1. The guide initializes func initComponents(state AppState) AppState {
if state.Toasts == nil {
state.Toasts = toast.New("notifications", ...)
}
return state
}On a reconnect ( 2. The guide's sample implementation: result := c.renderedJSON
c.renderedJSON = ""
return resultOn the second call (diff-tree pass), the cache is cleared. This is correct for 2 passes, but the comment says "Any further call (next action, no new data): returns 3. The Mount example calls state.Toasts.SetStyled(false) // omit default border/shadow styles; use your own CSS4. Client file paths are ambiguous The guide mentions
5. The note tells readers to use livetemplate.AssertPureState[AppState](t, livetemplate.IgnoreFields("Toasts"))(or whatever the actual API looks like) Minor nits
Positives worth keeping
Overall the guide is solid. The above are genuine gaps (silent data loss on reconnect, ambiguous file paths) and missing context (unexplained API calls) that would trip up readers implementing this for the first time. |
Summary
docs/guides/ephemeral-components.md— documents the trigger-attribute pattern for client-side toasts, alerts, bannersTakePendingJSON()drain pattern, morphdom CSS behavior, client directive skeletonprogressive-complexity.mdTest plan
🤖 Generated with Claude Code