Skip to content

docs: add ephemeral-components guide - #316

Merged
adnaan merged 3 commits into
mainfrom
docs/ephemeral-components
Apr 2, 2026
Merged

docs: add ephemeral-components guide#316
adnaan merged 3 commits into
mainfrom
docs/ephemeral-components

Conversation

@adnaan

@adnaan adnaan commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New guide: docs/guides/ephemeral-components.md — documents the trigger-attribute pattern for client-side toasts, alerts, banners
  • Covers: why not to put them in the diff tree, TakePendingJSON() drain pattern, morphdom CSS behavior, client directive skeleton
  • Add cross-reference from progressive-complexity.md

Test plan

🤖 Generated with Claude Code

…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>
Copilot AI review requested due to automatic review settings April 2, 2026 02:22
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

PR Review: docs: add ephemeral-components guide

Overall 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

TakePendingJSON ignores marshal errors (line ~159)

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 json.RawMessage to pre-validate. At minimum a comment: // handle error in production.

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 hasNewData + renderedJSON cache logic in TakePendingJSON only makes sense once you understand why templates get evaluated twice. A brief inline explanation (or a > Note: callout pointing to the relevant part of the architecture docs) would remove ambiguity.

initComponents called in Sync but not shown

Mount and OnConnect examples are shown, but Sync is mentioned in prose without an example. Since Sync runs on peer connections after cross-tab actions, a missing initComponents(state) call there would cause a nil-pointer panic. Worth showing the pattern explicitly for all three lifecycle hooks, or at least adding a code comment.

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 div, set textContent, append to a stack container) would make this actionable.


Minor Suggestions

data-pending attribute value is not HTML-escaped

<span
  data-alert-trigger="{{$c.ID}}"
  {{- if $pending}} data-pending='{{$pending}}'{{end}}

JSON can contain single quotes in string values (it's), which would break the attribute since the template uses single-quote delimiters. html/template does handle attribute context escaping, but it's worth a note confirming that the TakePendingJSON() output is safe to embed in single-quoted HTML attributes, or switching to double quotes with {{$pending | html}}.

$c.TakePendingJSON vs $c.TakePendingJSON — no parentheses explanation

Go templates call zero-argument methods without (), which differs from function calls and can trip up developers coming from other templating languages. A brief note would help.

"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 "Why Not Put Them in the Diff Tree?" section is exactly the right framing — explains motivation before mechanics.
  • The morphdom/CSS section addresses a genuinely surprising behavior and the solution is clean.
  • The anti-patterns table is concise and accurate.
  • The progressive-complexity.md back-link is correctly relative (same directory).
  • The aria-hidden="true" on the trigger span is good accessibility hygiene.

The core pattern and explanation are solid. Addressing the missing Sync example and fleshing out the client directive stub would make this a complete, copy-pasteable guide.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md guide 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.

Comment thread docs/guides/ephemeral-components.md Outdated
Comment on lines +13 to +15
- **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

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Suggested change
- **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

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +31
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>

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread docs/guides/ephemeral-components.md Outdated

### Initialization

Initialize the container in `Mount`, `OnConnect`, and `Sync` — the three lifecycle hooks that run on fresh state:

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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:

Copilot uses AI. Check for mistakes.
Comment thread docs/guides/ephemeral-components.md Outdated
{{ 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.

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
Comment on lines +153 to +168
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
}

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 &#34;). 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().

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +41
## Server Side: The Component

The `lvt/components/toast` package provides a `Container` that queues messages and serializes them on demand.

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +225 to +232
## 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 |

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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>
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

Review: docs/guides/ephemeral-components.md

This is a well-structured, clearly motivated guide. The rationale is sound, the anti-patterns table is useful, and the double-evaluation problem with TakePendingJSON() is explained correctly. A few issues worth addressing before merge:


1. *toast.Container in state conflicts with AssertPureState[T]()

The guide tells developers to add *toast.Container to their state struct:

type AppState struct {
    Toasts *toast.Container
}

But CLAUDE.md describes AssertPureState[T]() as a test helper that "Fails if State contains dependency types (DB, Logger, etc.)". A *toast.Container is a non-serializable composite type, so AssertPureState[AppState](t) will likely fail for anyone following the testing recommendations.

The guide should either:

  • Explicitly warn that AssertPureState will need an exemption/allowlist for this field, or
  • Explain how *toast.Container is treated differently from dependency types (e.g. why it's acceptable here but *sql.DB would not be), or
  • Suggest a workaround (e.g. skip AssertPureState for state structs with component fields, or restructure so only primitive state lives in AppState)

2. initComponents code example only shows Mount — but text says three places

The guide states:

Initialize the container wherever non-persistent fields may be nil — Mount (first connection), OnConnect (reconnection), and Sync (cross-connection state sync)

But the code only demonstrates Mount. Developers who don't implement OnConnect / Sync will hit a nil-pointer panic on WebSocket reconnect or cross-tab sync. Show all three, or at minimum show the pattern once and note the other two explicitly in a callout.


3. Subtle behavior in TakePendingJSON second call

The example drain implementation clears renderedJSON on the second call:

result := c.renderedJSON
c.renderedJSON = ""   // <-- cleared here
return result

This means a third call returns "". That is presumably intentional (once both passes have fired, the data is gone), but the comment "The hasNewData flag + renderedJSON cache ensures both the HTML pass and the diff-tree pass see the same value" undersells it — it would be clearer to say "the first call marshals and caches, the second call returns the cache and resets it, and any further call returns empty." A brief inline comment to that effect in the code snippet would prevent confusion.


4. CSS/morphdom claim is implementation-specific

CSS dynamically injected into <head> via JS is also removed on each server update, because the injected <style> element is not in the server-rendered <head> and morphdom diffs it away.

This is true for LiveTemplate's default morphdom configuration, but the wording implies it is a universal morphdom behavior. If the client is ever configured with onBeforeElUpdated or childrenOnly, the behavior could differ. Scope the statement: "In LiveTemplate's DOM update strategy, dynamically injected <style> elements in <head> are removed on each server update."


Minor

  • The container.tmpl snippet in the "CSS" section uses a hardcoded data-toast-trigger="..." placeholder — this should show {{$c.ID}} (matching the step-by-step example further down) to avoid confusion about where the ID comes from.
  • The guide never links to the actual github.com/livetemplate/lvt/components/toast package docs or source. Even a parenthetical (source: livetemplate/lvt) would help readers find the real API.

Overall the content is accurate and fills a genuine gap. The AssertPureState conflict is the most important item to resolve before this lands.

…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>
@claude

claude Bot commented Apr 2, 2026

Copy link
Copy Markdown

PR Review: Ephemeral Components Guide

This 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:


Issues

1. initComponents silently drops pending toasts on reconnect

The guide initializes Toasts unconditionally if nil:

func initComponents(state AppState) AppState {
    if state.Toasts == nil {
        state.Toasts = toast.New("notifications", ...)
    }
    return state
}

On a reconnect (OnConnect), the previous container is nil (non-persistent), so a fresh one is created. Any messages queued by a concurrent action between the disconnect and reconnect are silently lost. This is probably acceptable behavior for ephemeral components, but the guide should acknowledge it rather than leave readers wondering. A sentence like: "Messages queued between a disconnect and reconnect are not replayed — this is expected behavior for ephemeral components."


2. TakePendingJSON second-call clears the cache prematurely

The guide's sample implementation:

result := c.renderedJSON
c.renderedJSON = ""
return result

On 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 """ — which is also correct. However, if the framework ever adds a third evaluation pass (e.g. for streaming), this pattern silently drops data on that third call with no error. Worth noting as a constraint: "This implementation assumes exactly two evaluation passes per action."


3. SetStyled(false) is unexplained

The Mount example calls state.Toasts.SetStyled(false) without any explanation. Readers will wonder what this does and whether they need it. A brief inline comment would help:

state.Toasts.SetStyled(false) // omit default border/shadow styles; use your own CSS

4. Client file paths are ambiguous

The guide mentions client/dom/directives.ts and livetemplate-client.ts without saying which repository they live in. Since the client is a separate repo (github.com/livetemplate/client), the "Adding a New Ephemeral Component" section should clarify:

"Add a directive in dom/directives.ts in the livetemplate/client repository"


5. AssertPureStateIgnoreFields option not shown

The note tells readers to use AssertPureState with the IgnoreFields option but doesn't show the call signature. Either show a brief example or link to the API docs so readers don't have to go hunting:

livetemplate.AssertPureState[AppState](t, livetemplate.IgnoreFields("Toasts"))

(or whatever the actual API looks like)


Minor nits

  • The anti-pattern "Write custom JS in the app template" could be more precise — the guide itself shows TypeScript to add to the client library. Something like "Inject <script> tags or inline JS into the consuming app template" distinguishes it from legitimate client library additions.
  • The three-call contract explanation is excellent, but the numbering in the list skips from "second call" to "any further call" — calling it "call 3+" would be cleaner.

Positives worth keeping

  • The "Why Not Put Them in the Diff Tree?" rationale is concise and persuasive — good framing for the whole guide.
  • The CSS-in-component-template explanation is subtle and easy to miss in practice; the morphdom context makes it concrete.
  • HTML escaping safety note is thorough and accurate.
  • The anti-patterns table adds real value.

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.

@adnaan
adnaan merged commit 407df12 into main Apr 2, 2026
1 check passed
@adnaan
adnaan deleted the docs/ephemeral-components branch April 2, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants