Conversation
Implements the trigger-attribute pattern for ephemeral components: - handleToastDirectives() reads data-pending on [data-toast-trigger] spans and creates fully client-managed toast DOM after each DOM update - setupToastClickOutside() dismisses toasts on outside click (called at connect) - injectToastStyles() injects positioning CSS; called every update cycle so it survives morphdom patching of <head> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s styles CSS for client-managed DOM belongs in the component template (<style> tag), not injected into <head> by JS. Template CSS survives morphdom patches because it is part of the server-rendered HTML on every response. JS-injected styles were being removed on each DOM patch. Remove injectToastStyles() and all its call sites; simplify getOrCreateToastStack. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewOverall looks solid — XSS is safe (textContent throughout), the dedup guard via Bugs / Issues
Minor
|
There was a problem hiding this comment.
Pull request overview
Adds a new client-side “toast directive” flow to LiveTemplate: server-rendered trigger elements provide data-pending JSON, and the client builds/controls an out-of-band toast stack (including click-outside dismissal) without relying on server DOM persistence.
Changes:
- Added
handleToastDirectives()to parsedata-pendingfrom[data-toast-trigger]elements and append toast DOM into a global stack. - Added
setupToastClickOutside()to dismiss all toasts when clicking outside the stack. - Wired toast handling into
LiveTemplateClient.connect()(listener setup) andupdateDOM()(directive execution after morphdom).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
livetemplate-client.ts |
Calls toast setup at connect time and runs toast directive handling after each DOM patch. |
dom/directives.ts |
Implements toast stack creation, toast rendering, auto-dismiss timers, and click-outside dismissal logic. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let messages: ToastMessage[]; | ||
| try { | ||
| messages = JSON.parse(pending); | ||
| } catch { | ||
| return; | ||
| } | ||
| if (!messages.length) return; | ||
|
|
||
| const stack = getOrCreateToastStack(); | ||
| messages.forEach((msg) => { | ||
| const el = createToastElement(msg); | ||
| stack.appendChild(el); | ||
| if (msg.dismissMS > 0) { | ||
| setTimeout(() => el.remove(), msg.dismissMS); | ||
| } | ||
| }); |
There was a problem hiding this comment.
JSON.parse(pending) is assumed to return ToastMessage[], but runtime input could be a non-array (e.g. a string with a .length), which would pass the !messages.length guard and then throw at messages.forEach. Consider guarding with Array.isArray(...) (and optionally validating each item is an object with required fields) before iterating to avoid client-side crashes from malformed data-pending values.
| id: string; | ||
| title?: string; | ||
| body?: string; | ||
| type: "info" | "success" | "warning" | "error"; |
There was a problem hiding this comment.
ToastMessage.type is currently unused when building the toast DOM, so the rendered markup can’t reflect the toast variant (info/success/warning/error). If styling/behavior depends on the type, consider adding a data-* attribute or class based on msg.type (or removing the field if it’s not intended to be used).
| type: "info" | "success" | "warning" | "error"; |
| /** | ||
| * Read data-pending toast messages from server trigger elements and create | ||
| * client-managed toast DOM. Called after each LiveTemplate DOM update. | ||
| */ | ||
| export function handleToastDirectives(rootElement: Element): void { | ||
| rootElement | ||
| .querySelectorAll<HTMLElement>("[data-toast-trigger]") | ||
| .forEach((trigger) => { | ||
| const pending = trigger.getAttribute("data-pending"); | ||
| if (!pending) return; | ||
| // Skip if this exact batch was already processed (handles multi-patch calls) | ||
| if ((trigger as any)[PENDING_PROCESSED_KEY] === pending) return; | ||
| (trigger as any)[PENDING_PROCESSED_KEY] = pending; | ||
|
|
||
| let messages: ToastMessage[]; | ||
| try { | ||
| messages = JSON.parse(pending); | ||
| } catch { | ||
| return; | ||
| } | ||
| if (!messages.length) return; | ||
|
|
||
| const stack = getOrCreateToastStack(); | ||
| messages.forEach((msg) => { | ||
| const el = createToastElement(msg); | ||
| stack.appendChild(el); | ||
| if (msg.dismissMS > 0) { | ||
| setTimeout(() => el.remove(), msg.dismissMS); | ||
| } | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
New toast behavior is introduced here (parsing data-pending, creating a global toast stack, click handlers, and auto-dismiss timers), but there are no corresponding unit tests alongside the existing directive tests. Adding tests for: (1) valid JSON array renders toast elements, (2) malformed/non-array JSON is ignored safely, (3) dismissMS removes toasts via fake timers, and (4) dismissible button removes a toast would help prevent regressions.
Addresses bot review: guard against non-array JSON.parse results, and expose toast type as data-type attribute for CSS styling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Review: feat: add client-side toast directive Overall clean implementation. A few issues worth addressing: Bug: No runtime validation of parsed JSON
if (typeof msg.dismissMS !== 'number' || msg.dismissMS > 0) { ... }Or validate the shape before use. Code quality: Using const processedPending = new WeakMap<HTMLElement, string>();
// ...
if (processedPending.get(trigger) === pending) return;
processedPending.set(trigger, pending);UX concern: click-outside dismisses ALL toasts simultaneously
Minor: The stack has |
Guard against non-number dismissMS values from JSON to prevent silent auto-dismiss failures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReviewGood overall. XSS vectors are safe (.textContent throughout), JSON parse is guarded, and the dedup logic via the expando key is sound. Issues worth fixing: 1. Click-outside discards unread error toasts 2. msg.id is not validated before use as an attribute 3. dismissMS: Infinity creates a permanently stuck toast Minor:
|
Read data-position from the trigger span and apply position-specific inline styles to the toast stack. CSS defaults to top-right; client overrides for top-left, top-center, bottom-right, bottom-left, bottom-center. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Review: feat: add client-side toast directive Good overall — the XSS surface is clean ( Two bugs worth fixing: 1. Click-outside handler dismisses non-dismissible toasts // current
stack.querySelectorAll("[data-lvt-toast-item]").forEach((el) => el.remove());
// suggested
stack.querySelectorAll("[data-lvt-toast-item]").forEach((el) => {
if (el.querySelector("button[aria-label='Dismiss']")) el.remove();
});(or store the flag as a 2. Position is silently ignored after the first stack is created |
Summary
handleToastDirectives()indom/directives.ts— readsdata-pendingJSON from server trigger elements and creates fully client-managed toast DOMsetupToastClickOutside()— dismisses all toasts on outside click (called once at connect)livetemplate-client.ts(updateDOMandconnect)Test plan
todos/E2E tests pass with toast position:fixed assertion🤖 Generated with Claude Code