Skip to content

feat: lvt:error CustomEvent for topic_forbidden envelope (livetemplate#415, V14)#121

Merged
adnaan merged 3 commits into
mainfrom
broadcast-redesign-phase-4
May 20, 2026
Merged

feat: lvt:error CustomEvent for topic_forbidden envelope (livetemplate#415, V14)#121
adnaan merged 3 commits into
mainfrom
broadcast-redesign-phase-4

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented May 19, 2026

Phase 4 of livetemplate's broadcast-action-redesign (#415). Adds the TS consumer for the topic-ACL error envelope the livetemplate server emits on an ACL-denied ctx.Subscribe in the WS-connect Mount.

Companion PR: livetemplate/livetemplate#427 — the Option B keep-open server change + V14 Tier-1 regression + canonical phase-4.md learnings.

Change

livetemplate-client.ts handleWebSocketPayload: new first-discriminator early-return branch — a typed local cast errorEnvelope to {type?, code?, topic?}, and on errorEnvelope.type === "error" dispatches a non-bubbling CustomEvent("lvt:error", { detail: { code, topic } }) on this.wrapperElement and returns before the diff path (analyzeStatics/updateDOM never see a treeless payload). Mirrors the existing lvt:updated idiom at the same call site.

V14 contract (byte-for-byte, three-tier agreement)

server:  {"type":"error","code":"topic_forbidden","topic":"<denied>"}
client:  CustomEvent("lvt:error", { detail: { code, topic } }) on wrapper

Asserted in three tiers: livetemplate topic_test.go (Tier 1 server), tests/topic-error-envelope.test.ts here (client logic leg), and the lvt chromedp e2e (Tier 2 user-visible). The server keeps the socket OPEN after emitting (livetemplate Phase 4 keep-open), so no disconnect to handle here.

Name overlap (non-functional, documented inline)

state/form-lifecycle-manager.ts also dispatches an lvt:error event — but on the <form> element with a ResponseMetadata detail. These are two distinct, non-bubbling events disambiguated by target (wrapper vs <form>) and detail shape — they do not collide at any listener. Pinned per spec; livetemplate's Phase 6 docs rewrite will distinguish.

Test (tests/topic-error-envelope.test.ts, new)

4 jest tests gating V14's logic leg:

  1. Exact { code, topic } detail dispatched on the wrapper.
  2. Target-specific: a bubble-phase document listener does not observe the wrapper-dispatched event (proving bubbles:false), while a direct listener on the wrapper does. (Round 3: corrected — earlier wording mischaracterized the test as a capture-phase assertion.)
  3. Diff path NOT entered (updateDOM spied: not called; no lvt:updated; DOM untouched).
  4. Over-match guard: a normal UpdateResponse still flows to updateDOM and does not fire lvt:error.

Gate (pre-commit ran on this commit)

npm test: 29 suites, 551 tests, 100% pass. npm run build clean (produces dist/livetemplate-client.browser.js the cross-repo lvt e2e consumes).

Cross-repo coordination

Companion PRs in release order (this + livetemplate#427 are wire-independent; lvt e2e gates last):

  • livetemplate#427 — server keep-open change + V14 Tier-1 + canonical learnings.
  • lvt (follow-up) — V14 Tier-2 chromedp e2e + committed go.mod replace (Phase-5-resolved cross-repo dependency artifact).

🤖 Generated with Claude Code

Adds a new mode to lvt-fx:scroll. The existing modes (top, bottom,
bottom-sticky, preserve) all scroll a *container* to an absolute
position — they're built for chat panes and infinite scrolls.
into-view is the opposite: it scrolls the target element into the
viewport via element.scrollIntoView({block: "center"}).

This lets server state drive "scroll this comment into view after a
jump" with zero app-level JS — set a "ScrollToCommentID" field, put
`lvt-fx:scroll="into-view"` on the matching element, and the directive
handler does the rest.

Single-shot per element via a data-lvt-iv-done attribute on the DOM
node. Without this guard, reactive re-renders would repeatedly yank
the page back to the same target every time unrelated state changes.
Storing the seen-it bit on the element (not the controller) means it
travels through reactivity churn and resets naturally when the
element is replaced.

3 new tests cover: basic scroll fires with correct arguments, honors
the --lvt-scroll-behavior CSS variable, and the one-shot guard
(fires only once across multiple handleScrollDirectives calls).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Review

Critical: node_modules symlink must not be committed

The diff includes a symlink pointing to an absolute path on the developer machine (/home/adnaan/...) that was accidentally committed. This will break every other checkout and CI environment. Before merging: (1) git rm node_modules, (2) ensure node_modules is in .gitignore, (3) amend/force-push to remove from history.


handleWebSocketPayload — over-broad type discriminator

The errorEnvelope.type === 'error' check will intercept any future server message with type: 'error', not just topic-ACL envelopes. If the server ever emits other error shapes (rate limits, auth failures, etc.) through the same socket, they would be silently short-circuited and surfaced only as lvt:error on the wrapper with no diff path processing. Consider being more specific by also checking errorEnvelope.code, or add a comment locking the assumption that only topic-ACL errors use this envelope shape on this socket.


Rest of the change looks good. The into-view one-shot guard via data-lvt-iv-done is clean, the non-bubbling CustomEvent correctly avoids collision with the form-level lvt:error, and the test coverage across all three scenarios is thorough.

…e#415, V14)

Phase 4 of livetemplate's broadcast-action-redesign (#415). Adds the TS
consumer for the topic-ACL error envelope the livetemplate server emits
on an ACL-denied ctx.Subscribe in the WS-connect Mount.

## Change

livetemplate-client.ts handleWebSocketPayload: new FIRST-discriminator
early-return branch -- a typed local cast errorEnvelope to {type?, code?,
topic?}, and on errorEnvelope.type === "error" dispatches a non-bubbling
CustomEvent("lvt:error", { detail: { code, topic } }) on
this.wrapperElement and returns BEFORE the diff path
(analyzeStatics/updateDOM never see a treeless payload). Mirrors the
existing lvt:updated idiom.

## V14 contract (byte-for-byte, three-tier agreement)

  server   {"type":"error","code":"topic_forbidden","topic":"<denied>"}
  client   CustomEvent("lvt:error", {detail:{code, topic}}) on wrapper

Asserted in three tiers: livetemplate topic_test.go (Tier 1),
tests/topic-error-envelope.test.ts here (client logic leg), and the lvt
chromedp e2e (Tier 2 user-visible). The server keeps the socket OPEN
after emitting (livetemplate Phase 4 keep-open), so no disconnect to
handle here.

## Name overlap (non-functional, documented inline)

state/form-lifecycle-manager.ts also dispatches an `lvt:error` event,
but on the <form> element with a ResponseMetadata detail. These are two
distinct, non-bubbling events disambiguated by target (wrapper vs form)
and detail shape -- they do not collide at any listener. Pinned per
spec; livetemplate Phase 6 docs will distinguish.

## Test (tests/topic-error-envelope.test.ts, new)

4 jest tests gating V14's logic leg:
1. Exact { code, topic } detail dispatched on the wrapper.
2. Target-specific: capture-phase document listener observes it (capture
   traverses regardless of bubbles:false), but document bubble-phase
   does NOT receive it.
3. Diff path NOT entered (updateDOM spied: not called; no lvt:updated;
   DOM untouched).
4. Over-match guard: a normal UpdateResponse still flows to updateDOM
   and does NOT fire lvt:error.

## Gate

`npm test`: 29 suites, 551 tests, 100% pass. `npm run build` clean
(produces dist/livetemplate-client.browser.js the cross-repo lvt e2e
consumes).

## Cross-repo coordination

Companion PRs in dependency order (release order: this + livetemplate
are wire-independent; lvt e2e gates last):

- companion: livetemplate/livetemplate broadcast-redesign-phase-4 --
  Option B keep-open server change (mount.go) + V14 Tier-1 regression +
  proposal Phase-4 tracker/learnings (the canonical learnings file).
- companion: livetemplate/lvt broadcast-redesign-phase-4 -- V14 Tier-2
  chromedp browser e2e + committed go.mod replace
  (Phase-5-resolved cross-repo dependency artifact).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@adnaan adnaan force-pushed the broadcast-redesign-phase-4 branch from f2d5edf to e0244a1 Compare May 19, 2026 21:49
@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Review — PR #121

livetemplate-client.ts — error envelope handling

Silent drop when wrapperElement is null. this.wrapperElement?.dispatchEvent(...) silently swallows the error if the wrapper is not set yet. An error arriving before the element is wired up would be lost with no log or throw. At minimum, add a console.warn so it is visible in production.

Over-broad type match. The guard errorEnvelope.type === 'error' will intercept any future payload shape that carries type: 'error', not just topic-ACL envelopes. Consider narrowing with an additional code check (e.g. ensuring code is present and a string) so other error-typed payloads are not accidentally short-circuited before the diff path.

dom/directives.tsinto-view scroll

The one-shot guard (data-lvt-iv-done) is correct. The behavior of re-scrolling when the server replaces the element entirely (new node = no attribute) versus patching in place appears intentional and matches the comment — just worth confirming that DOM diffing replaces rather than patches when the scroll target changes identity.

Tests

Good coverage of the happy path, behavior flag, and one-shot semantics. The non-bubbling assertion for lvt:error is particularly useful given the potential for confusion with the form-level event.

Summary

Two real issues worth addressing before merge: the silent error-swallow and the over-broad type match. Everything else looks solid.

…ode check)

PR #121 round 2 Claude review:

(1) 'Silent drop when wrapperElement is null': the previous
`this.wrapperElement?.dispatchEvent(...)` silently swallowed the error
if the wrapper was somehow not yet set. handleWebSocketPayload's
WS-onMessage call chain (connect() sets wrapperElement before WS opens)
makes this unreachable in practice, but a silent drop in a hypothetical
mis-order would be invisible. Now: dispatch on the wrapper if set,
otherwise this.logger.warn — visibly logged in production.

(2) 'Over-broad type match': narrow with a structural check
`typeof errorEnvelope.code === 'string'`. A real V14 envelope MUST
carry a string code; a bare `{type:'error'}` without one is malformed
(or a non-envelope payload that happens to set type:'error'). Critically
we still SHORT-CIRCUIT the diff path in that case (don't fall through
to analyzeStatics(undefined) which would error) — instead log + drop.

(3) Added a 5th jest test covering the new defensive path: feeding
`{type:'error'}` (no code) and `{type:'error',code:42}` (non-string
code) asserts no lvt:error dispatch + no updateDOM call (no fall-through
to the diff path with a treeless payload).

Verified: npx jest tests/topic-error-envelope.test.ts — 5/5 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Review

Overall the implementation is clean and the test coverage is solid. Two things worth flagging before merge:

1. data-lvt-iv-done is invisible to the server — the "re-scroll to same element" path may be broken

The one-shot guard (htmlElement.dataset.lvtIvDone = "1") is set client-side, which means the server/template has no direct way to clear it. The comment says the directive fires again on "new element or new value (e.g. jumping to a different comment)", but:

  • The attribute value is always the literal string "into-view" — there's no second value to change to.
  • If the same DOM node is reused for a different comment (server sends the same element with updated content), the guard won't reset unless the DOM diff explicitly removes the data-lvt-iv-done attribute from that node.

Does livetemplate's diff strip client-added data-* attributes it doesn't recognize, or preserve them? If it preserves them, the re-scroll case silently breaks. Worth a quick integration test or a clarifying comment about the intended reset mechanism.

2. PR description misstates the bubbling test

"capture-phase document listener observes it (capture traverses regardless of bubbles:false)"

The actual test uses a bubble-phase listener (document.addEventListener("lvt:error", onDocument) — no third argument), which correctly doesn't observe the event. The behavior is right, but the PR description implies a capture-phase assertion that isn't there. Minor, but could confuse anyone reading this later.


The error envelope short-circuit (type === "error" before the diff path) and the malformed-code guard are well-reasoned. No concerns there. LGTM pending the data-lvt-iv-done lifetime question.

@adnaan
Copy link
Copy Markdown
Contributor Author

adnaan commented May 19, 2026

Round-3 review responses:

1. data-lvt-iv-done re-scroll concern: out of PR scope. The into-view directive shipped in client commit b00ef39 feat: lvt-fx:scroll="into-view" directive for jump-to-element which is already on main (merged before this PR was opened). The PR diff for this V14 PR vs main does NOT include dom/directives.tsgit diff main...HEAD --name-only shows only livetemplate-client.ts, tests/topic-error-envelope.test.ts, and .gitignore. The directive bot caught is genuine, but it's a separate concern that needs a separate PR against main, not a Phase-4 V14 follow-up. Flagging it as a follow-up issue is reasonable; addressing it here would conflate two unrelated changes.

2. PR description misstates the bubble test: ✅ fixed in the PR body — the test uses a bubble-phase document listener and asserts non-observation (proving bubbles:false), not capture-phase observation. Description updated.

No other functional items raised in round 3.

@adnaan adnaan merged commit 6d1d5b8 into main May 20, 2026
6 checks passed
@adnaan adnaan deleted the broadcast-redesign-phase-4 branch May 20, 2026 05:56
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.

1 participant