Skip to content

feat(attributes): make lvt-* attributes extensible via a handler registry - #159

Open
adnaan wants to merge 5 commits into
mainfrom
issue-473-phase-1
Open

feat(attributes): make lvt-* attributes extensible via a handler registry#159
adnaan wants to merge 5 commits into
mainfrom
issue-473-phase-1

Conversation

@adnaan

@adnaan adnaan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of livetemplate/livetemplate#473. The governing plan is docs/plans/issue-473.md in the livetemplate repo.

Why

Adding an lvt-* attribute meant forking this library: write a dom/*.ts module, import it into livetemplate-client.ts, insert a call at the right position in a hardcoded ~20-entry post-render sequence, and add a matching entry to disconnect()'s teardown list. An app that wanted lvt-x:copy-to-clipboard had no supported way to get one.

The second half of the problem is the mirror image: ~2,400 lines of handlers with exactly one consumer (prereview) ship to every LiveTemplate user, because there was no mechanism for a downstream app to own them.

What this does

Replaces both hardcoded lists with a registry of handler objects plus a public LiveTemplateClient.registerAttribute(). Every built-in registers through the same interface an external author uses — there is no privileged internal path, so a gap in the public API shows up as a gap for the framework itself.

Two layers. The declarative one (declare an attribute name, get called per element) is what most authors should touch:

LiveTemplateClient.registerAttribute({
  attribute: "lvt-x:copy",
  onElementAdded(el, ctx) {
    el.addEventListener("click", () => navigator.clipboard.writeText(ctx.value));
  },
});

The framework owns selector escaping, the scan, per-element tracking, empty-value rejection and sweep-on-removal — which makes the three classic handler bugs unreachable from this layer: no sweep, listeners stacking on every render, and a captured transport that dies at reconnect. A selectors + setup()/teardown() escape hatch covers cross-element state; the built-ins use it, because their bodies are unchanged and their existing tests call them directly.

Registration is static, and late registration catches up immediately. Under the documented <script defer> pattern, autoInit() has already constructed and connected a client by the time a second script evaluates — so an instance method could never have worked, and a handler registered after connect has to reach the DOM that is already on screen.

registerAttribute is a module-level named export, mirrored as a class static. --global-name=LiveTemplateClient over a module that also exports a class of that name makes window.LiveTemplateClient the module namespace, so the spelling every doc example uses resolves to the export, not the static. Both spellings are pinned by tests against the built bundle.

Verification

Check Result
Pre-existing tests 824 pass, zero edits to existing test files
New tests 37 (registry 27, built-in wiring 7, bundle surface 2) — 861 total
chromedp E2E 19/19 PASS against this build (livetemplate/lvt PR pairs with this one)
Bundle 141,102 → 146,753 B (+5.5 KB, +4.0%)

The 824 unedited tests are the load-bearing evidence: they call the handler functions directly, so their passing means only how handlers are called changed.

E2E ran by overwriting lvt's test-client disk cache with this build. -count=1 is mandatory there — without it go test replays a cached result and the suite "passes" against a bundle it never loaded, which happened once during development and was caught by the byte-size check in the log line.

Two bugs this found in its own contract

ctx.send was stale-by-construction. The first implementation built ElementContext with a getter closing over the dispatch parameter — still a capture, because onElementAdded fires once, so the listener it wires holds render #1's context for the life of the page. A reconnect would dispatch into the dead transport. Now resolved through a WeakMap the accessor reads. Benign in production today (the client's send is a thin delegate), but Phase 3 publishes this contract, so it has to be true rather than accidentally true.

A severed array literal registered 4 of 18 handlers, and all 861 tests stayed green. Collapsing three handler arrays into one left 14 handlers as orphaned expression statements — valid JS, so tsc was silent, and the behavioural suites never touch the registry. The only signal was the bundle dropping 143.2 → 112.4 KB as esbuild tree-shook them. tests/builtin-handlers.test.ts now pins the set, the order, the six needsServerChannel handlers, the single wire-idempotent entry and the lone dispose — and it is what will make Phase 4's relocation visible rather than silent.

Scope

Phase 1 only. Phase 2's client half — the meta.attributes census diff and the unhandled-attribute warning — is assigned to Phase 3. It needs an allowlist covering all 46 census names including the routing namespaces, or every app warns on first render; that allowlist is the real cost and is unrelated to this refactor.

Consequence, stated so it is owned: M2 (extraction) must not start before Phase 3 lands it. That warning is the only thing that makes Phase 4's break loud instead of silent.

Open questions handed to Phase 3

  • Connect-time arming should be a lifecycle phase, not two hardcoded calls. url-hash and initializeFileInputs (#453) both arm at connect for the identical reason — a page load produces no updateDOM call — and any third-party handler over SSR'd markup needs the same. An opt-in runOnConnect would express it. Not done here because it moves those calls relative to the rest of connect(), and this phase's bar is that nothing changes behaviour.
  • Whether always earns its place. Behaviourally identical to fire-on-change today, no consumer. Kept because the plan's L3 decided three categories, but if Phase 3 still has no consumer, drop it. delegatedEvents was removed on exactly this reasoning — public surface with no consumer — and Phase 3 adds it when it consumes it.
  • Ordering is preserved, not designed. No non-commuting pair is known. Phase 4 moves ten handlers to a bundle that necessarily evaluates after core, changing their relative order; if that matters, order has to become something the interface expresses (a coarse stable-sorted phase — never integer priorities, which in a public registry become a z-index war).

🤖 Generated with Claude Code

https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq

…stry

Adding an lvt-* attribute meant forking this library: writing a dom/*.ts
module, importing it into livetemplate-client.ts, and inserting a call at
the right position in a hardcoded ~20-entry post-render sequence, with a
matching entry in disconnect()'s teardown list. An app that wanted
lvt-x:copy-to-clipboard had no supported way to get one, and ~2,400 lines
of handlers with exactly one consumer shipped to every LiveTemplate user
because there was no mechanism for a downstream app to own them.

Replace both hardcoded lists with a registry of handler objects and a
public LiveTemplateClient.registerAttribute(). Every built-in registers
through the same interface an external author uses -- there is no
privileged internal path, so a gap in the public API shows up as a gap for
the framework itself.

Two layers. Declarative (declare an attribute NAME, get called per
element) is what most authors should touch; the framework owns selector
escaping, the scan, per-element tracking, empty-value rejection and
sweep-on-removal, which makes the three classic handler bugs -- no sweep,
listeners stacking every render, a captured transport that dies at
reconnect -- unreachable from it. A selectors + setup()/teardown() escape
hatch covers cross-element state; the built-ins use it, because their
bodies are unchanged and their existing tests call them directly.

Registration is static, and late registration catches up against the live
DOM immediately: under the documented <script defer> pattern autoInit()
has already connected by the time a second script evaluates, so an
instance method could never have worked. registerAttribute is a
module-level named export because --global-name over a module that also
exports a class of that name makes window.LiveTemplateClient the module
namespace -- the spelling every doc example uses resolves to the export,
not the class static. Both are pinned by tests against the built bundle.

No behaviour change: all 824 pre-existing tests pass unedited, and lvt's
chromedp suite passes against the locally built bundle.

Phase 1 of livetemplate/livetemplate#473. Phase 2's client half (the
meta.attributes census diff) is assigned to Phase 3; M2 extraction must
not start before it lands, since that warning is what makes Phase 4's
break loud instead of silent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
@adnaan

adnaan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Paired browser coverage: livetemplate/lvt#344 (draft — it asserts this PR's API, so it stays red on lvt's main until the client releases with the registry).

Release order per the plan's cross-repo protocol: this PR → client release → un-draft and merge livetemplate/lvt#344.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the attribute-registry refactor. Solid design overall (live accessors for value/send, WeakMap-scoped tracking, static registration with late-registration catch-up). Two things worth a look before merge:

1. setup() and the querySelectorAll(selector) call aren't exception-isolated, unlike every other hook.

attribute-registry.ts carefully wraps onElementAdded, onElement, onElementRemoved, and dispose in try/catch via reportHookError, with a comment explicitly stating the design goal: "A third-party callback throwing must not abort the render or strand the handlers registered after it."

But runHandler()'s call to a low-level handler's handler.setup(...) (line ~465) and dispatchDeclarative()'s roots.scanRoot.querySelectorAll(resolved.get(handler)!.selector) (line ~567) are both unguarded. If either throws — a bug in a built-in setup() (e.g. handleScrollDirectives), or a malformed attribute name that lvtSelector doesn't fully escape (it only escapes :, not ", [, ], or whitespace) producing an invalid selector — the exception propagates out of runHandlers() and out of updateDOM() entirely. Since sendHTTP/the WS handler just log-and-swallow at that outer level, the render silently aborts partway through:

  • every handler registered after the failing one in getRegisteredAttributes() is skipped for that render,
  • eventDelegator.setupDOMEventTriggerDelegation(element) is skipped (it's called right after the registry loop, gated on the same domChanged),
  • this.instanceHandlers (file-upload wiring) never runs,
  • this.changeAutoWirer.wireElements() never runs.

Given this registry is now a public, no-build-step extension point aimed at app authors, worth wrapping those two call sites the same way the other four are wrapped.

2. Removal (onElementRemoved) can be silently delayed for the natural onElementAdded + onElementRemoved combo.

deriveCategory() only assigns fire-on-change when onElement is declared; a handler with just onElementAdded/onElementRemoved defaults to wire-idempotent. Since sweep() (which fires onElementRemoved) only runs when shouldRun() is true, a wire-idempotent handler's removal notification is skipped on any render that removes/detaches an element without also adding nodes elsewhere or touching a directive attribute. That's a plausible gap against the CHANGELOG's claim that the framework guarantees "sweeping elements that lost the attribute or left the DOM" out of the box — authors pairing onElementAdded + onElementRemoved (a very natural combo) get delayed cleanup unless they explicitly set category: "always". Might be worth deriving fire-on-change/always whenever onElementRemoved is present too, or calling it out explicitly in the JSDoc so authors know to opt in.

Nothing else stood out — the WeakMap-keyed tracking, transport-following ctx.send, and the build/bundle-surface test (public-api-surface.test.ts) are all nice touches.

…ther hook

Two defects found in review of #159.

1. onElementRemoved could be deferred indefinitely.

Gating the sweep on the handler's category broke the most natural
declarative shape there is: onElementAdded + onElementRemoved derives
`wire-idempotent`, so an element that lost its attribute kept its
listeners until some later render happened to add a node.

Worse, the flag can never see the render that matters.
`directiveTouchedThisRender` is set by a morphdom hook that inspects the
NEW element's attributes, so it detects an attribute being ADDED and
never one being REMOVED -- precisely the render this handler shape
exists to handle.

The sweep and the scan have opposite cost profiles and opposite failure
modes, so they now gate differently: the sweep is O(tracked) with two
O(1) reads and skipping it is a correctness bug, so it runs every
render; the scan is the querySelectorAll walk `wire-idempotent` exists
to skip, so it stays gated. The category never meant "skip cleanup".

2. setup() and the scan were the only unisolated paths.

Every other hook runs under reportHookError so that third-party code
throwing cannot strand the handlers after it -- but setup(), which is
the low-level layer's entire entry point, was unguarded. An exception
propagated out of updateDOM, silently skipping every later handler plus
the event delegator, the upload wiring and the change auto-wirer.

setup() is now wrapped. The scan is handled at the other end instead:
lvtSelector escapes ':' but not ']' or quotes, so a malformed attribute
name would throw SyntaxError on every render for the life of the page.
Validating once at registration turns a recurring render-time crash into
one actionable warning.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
@adnaan

adnaan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Both review findings were real and are fixed in cf8a32c. Reproduced each with a failing test first.

1. onElementRemoved deferred — worse than reported.

You flagged that a wire-idempotent handler's removal notification waits for a render that adds nodes or touches a directive. It's worse: that flag can never see the render in question. directiveTouchedThisRender is set by a morphdom hook that iterates the new element's attributes and checks !fromElement.hasAttribute(n) (livetemplate-client.ts:1991-2001) — so it detects an attribute being added and never one being removed. The render this handler shape exists to handle is precisely the one that cannot set the flag.

And it lands on the most natural shape there is: onElementAdded + onElementRemoved derives wire-idempotent.

Fix: the sweep and the scan gate differently now, because they have opposite cost profiles and opposite failure modes.

  • sweep — O(tracked), two O(1) reads per element. Skipping it leaves a disarmed element holding its listeners. Runs every render.
  • scan — the querySelectorAll walk that costs ~150-200ms at 80k descendants. Still gated; that is what the category is for.

The category never meant "skip cleanup". My test for this passed because it used the default domChanged = true; the new one pins false.

2. setup() unisolated — agreed, and it was the worst possible omission.

It is the low-level layer's entire entry point, so the one path most likely to run third-party code was the one path without reportHookError. Now wrapped.

For the selector I took the other end instead of wrapping the call. You're right that lvtSelector escapes only : — but a malformed name doesn't throw once, it throws on every render for the life of the page. Validating once at registration (document.createDocumentFragment().querySelector(selector) in a try) turns a recurring render-time crash into a single actionable warning, and keeps the hot path unguarded.

Verification: 865 unit tests green (4 new regression tests), and lvt's chromedp suite re-run against this build — 19/19.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff. Solid design overall (the live-accessor ElementContext, the sweep/scan split, per-handler error isolation, and the bundle-size regression test for the severed-array bug are all good calls), but found one real correctness gap:

onElementRemoved-only handlers silently never fire. In attribute-registry.ts, dispatchDeclarative() bails out immediately when neither onElementAdded nor onElement is defined:

const wantsAdded = !!handler.onElementAdded;
const wantsEvery = !!handler.onElement;
if (!wantsAdded && !wantsEvery) return;

Since elements are only added to the per-handler seen map inside this function, a handler that registers only onElementRemoved never gets any element tracked in the first place — so sweep() has nothing to report and the removal callback never runs. DeclarativeHandler makes all three callbacks optional, so this shape type-checks and registers without a warning; it just does nothing, forever. Worth either rejecting/warning at registration when onElementRemoved is declared without onElementAdded/onElement, or documenting that onElementRemoved requires one of the other two.

Minor: warning spam on a persistently-empty attribute value. In the same dispatch loop, an element whose attribute value is empty is never added to seen (by design, to allow it to "heal" once a real value shows up), but that means the logger.warn("... has an empty value; skipping ...") call fires again on every render the element persists with an empty value — e.g. once per keystroke if the template re-renders on input. Consider warning once per element (a WeakSet) rather than every render.

Note, not a blocker: dispose() hooks are invoked globally (disposeHandlers(getRegisteredAttributes())) on any single client's disconnect(), so on a page with multiple LiveTemplateClient instances, unmounting one tears down module-global state for handlers still in use by the others. This matches the pre-existing behavior for teardownAutoClickTimers() (which this replaces), so it's not a regression, but it's now a documented part of the public dispose() contract for third-party handlers and multi-client pages will hit it. Might be worth a callout in the registerAttribute docs.

Nothing else stood out — error isolation around third-party setup/onElementAdded/dispose callbacks is consistent, the duplicate-registration and invalid-selector checks are sound, and the late-registration catch-up path looks correct.

…ue rule

Review round 2 on #159.

1. onElementRemoved on its own silently did nothing, forever.

Elements are tracked by the scan, and the scan only runs for handlers
with something to call on a match -- so a declarative handler declaring
ONLY onElementRemoved tracked nothing, swept nothing, and never fired.
All three callbacks are optional, so the shape type-checked and
registered without complaint. Rejected at registration now, with a
message that names the missing callback. Same rule covers a declarative
handler with no callbacks at all.

2. The empty-value warning fired once per render, not once per element.

An element with an empty value is deliberately never tracked, so it can
heal the moment a real value arrives -- which meant one warning per
render for as long as it persisted, i.e. once per keystroke on a
template that re-renders on input. Warned once per element now, and the
mark is cleared when the element heals so a value that empties again is
reported again.

3. The scan and the sweep disagreed about what "empty" means.

Fixing (2) surfaced it: the scan refuses to arm an element whose value
is empty, while the sweep asked only hasAttribute() and so insisted an
emptied element was still live. A handler wired for {{.ShareURL}} kept
its listeners after the URL went away, and could never be re-armed
because it was still tracked.

One rule now, in both halves: an element is claimed while it carries the
attribute WITH a non-empty value. Emptying is treated exactly like
removal -- the value is the handler's configuration, so an empty one
means unarmed. Documented on DeclarativeHandler, where an author looks.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
@adnaan

adnaan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 — both findings fixed in df05809, and the first one surfaced a third problem underneath it.

1. onElementRemoved-only handlers silently never fire. Confirmed, and it is the exact failure mode this design is supposed to make impossible: it type-checks, registers without complaint, and does nothing forever. Rejected at registration now, with a message naming the missing callback. The same rule covers a declarative handler with no callbacks at all, which had the same silent-no-op shape.

I chose reject-with-warning over making it work. A handler that only learns about removals could never have wired anything on the way in, so it is an authoring error rather than a use case — and it sits alongside the other "cannot possibly work" rejections (both attribute and selectors; low-level without setup).

2. Empty-value warning spam. Fixed with a WeakSet, cleared when the element heals so a value that empties again is still reported.

3. Found while fixing (2): the scan and the sweep disagreed about what "empty" means.

The scan refuses to arm an element whose value is empty. The sweep asked only hasAttribute(), so it insisted an emptied element was still live. A handler wired for {{.ShareURL}} therefore kept its listeners after the URL went away — and could never be re-armed, because it was still tracked.

One rule now, in both halves: an element is claimed while it carries the attribute with a non-empty value. Emptying is treated exactly like removal. That is the rule the scan already implied, and it is documented on DeclarativeHandler where an author will look.

On the dispose() note: agreed, and agreed it is not a blocker — it is byte-for-byte the pre-existing behaviour, since teardownAutoClickTimers() was already an unconditional call in disconnect(). Multi-client pages have never been supported for module-global handler state. Worth fixing when the registry gains per-client handler ownership; I have recorded it against Phase 3 rather than widening this phase, whose bar is that nothing changes behaviour.

Verification: 870 unit tests green (5 new), lvt chromedp 19/19 against this build.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (attribute-registry.ts, dom/builtin-handlers.ts, event-spec.ts, the livetemplate-client.ts refactor, and the new test suites). The registry design and its docs/comments are unusually thorough, and the sweep/scan split, error isolation around third-party callbacks, and duplicate-registration warning all look solid. One correctness issue stood out that's worth fixing before merge:

Cross-client contamination of ctx.send / ctx.wrapperRoot for declarative handlers when multiple LiveTemplateClient instances are live at once

currentSend and currentRoots in attribute-registry.ts are module-level WeakMaps keyed only by the handler object, not by which client/root produced the render:

const currentSend = new WeakMap<AttributeHandler, SendFn>();
const currentRoots = new WeakMap<AttributeHandler, { scanRoot: Element; wrapperRoot: Element }>();

runHandler overwrites both on every call, regardless of which client called it:

if (channel && currentSend.get(handler) !== channel) currentSend.set(handler, channel);
...
currentRoots.set(handler, roots);

The registry itself is explicitly shared/module-level (registerAttribute is that on purpose), and liveRoots is a Set<RegistryRoot> — i.e. the design supports more than one concurrently-connected LiveTemplateClient on a page. If two client instances are both live and both contain elements matching the same declarative attribute (e.g. two independent widgets each using lvt-x:rating, each owned by its own client), whichever client rendered most recently wins the shared currentSend/currentRoots slot for that handler. A listener wired via onElementAdded on client A's element, if triggered after client B's next render, will call ctx.send and dispatch through client B's transport (wrong socket/session), and ctx.wrapperRoot will resolve to client B's wrapper.

That's exactly the failure mode ElementContext is documented to prevent ("a captured ctx survives a WebSocket reconnect instead of dispatching into a dead socket") — but because the cache is keyed by handler alone rather than by client/root identity, it silently breaks across concurrent clients rather than across reconnects. Consider keying these by the root/RegistryRoot (or building send/wrapperRoot per-scanRoot rather than storing "the current one" globally per handler).

The new tests/attribute-registry.test.ts suite is thorough but doesn't exercise two simultaneous liveRoots dispatching through the same declarative handler, so this gap wouldn't be caught by it.

Review round 3 on #159.

currentSend and currentRoots were module-level WeakMaps keyed by the
handler object alone. The registry is module-level BY DESIGN and
liveRoots is a Set, so two LiveTemplateClient instances can be live on
one page -- and if both contained elements matching the same declarative
attribute, whichever client rendered most recently owned the slot for
every other client's elements too. A listener wired on client A's
element, fired after client B's next render, dispatched through B's
socket and reported B's wrapper.

Both slots are gone. The transport and wrapper now live on the
per-element entry the tracking map already held, so they are written by
exactly one client: an element only ever matches during a dispatch whose
scanRoot contains it, which is its own client's. Cross-contamination
stops being possible rather than being avoided.

The fields are refreshed on every dispatch rather than captured at
creation, which is what keeps the reconnect guarantee that ElementContext
documents -- the client hands in a new transport and the element's own
next render adopts it. This is the same guarantee that motivated the
WeakMap indirection in the first place; the defect was its granularity,
not its existence.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
@adnaan

adnaan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Round 3 — confirmed and fixed in fff48e3. This one is squarely my bug: unlike the dispose() note from round 2, currentSend/currentRoots were new code, so "matches pre-existing behaviour" was never available as a defence.

Reproduced with three tests before touching the fix: two clients, both with elements matching lvt-x:rating, interleaved renders. A's listener dispatched through B's socket and reported B's wrapper.

The fix is structural rather than defensive. Both module-level slots are gone. The transport and wrapper now live on the per-element entry the tracking map already held — so they are written by exactly one client, because an element only ever matches during a dispatch whose scanRoot contains it, which is its own client's. Cross-contamination stops being possible rather than being avoided by care.

They are still refreshed on every dispatch rather than captured at creation, which preserves the reconnect guarantee ElementContext documents. Worth naming precisely: the WeakMap indirection existed because an earlier round of this same PR had ctx.send going stale across a reconnect. The indirection was right; its granularity was wrong. Per-element is the level that satisfies both properties at once.

This also retires the round-2 dispose() note more cleanly than I expected. That one genuinely is pre-existing and stays filed against Phase 3 — but the per-element pattern here is the shape the fix will take when the registry gains per-client handler ownership.

Verification: 873 unit tests green (3 new), lvt chromedp 19/19 against this build.

One test-authoring note, since it nearly produced a false pass: my first version of the reconnect test captured ctx with a bare ctx = c inside onElementAdded — which fires for both clients' elements, so it held B's context and failed for the wrong reason. Captured by value now.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff (attribute-registry.ts, dom/builtin-handlers.ts, event-spec.ts, livetemplate-client.ts + tests). Overall this is solid: the registry design (declarative vs low-level layers, live accessors for value/send, per-element transport keying for multi-client pages, sweep-before-shouldRun gating) is well thought out, and the three prior review rounds' regressions (deferred onElementRemoved, unfireable declarative shapes, cross-client transport leakage) all have dedicated tests pinning the fixes. No correctness or security issues found.

One minor nit, not blocking:

livetemplate-client.ts — misplaced/duplicated JSDoc block. Around the boundSend/registryRoot fields, there are two adjacent JSDoc comments:

/**
 * This client's face to the registry, so a handler registered AFTER connect
 * can immediately catch up ... Accessors, not fields: the wrapper is
 * reassigned on cross-handler navigation, and `send` must resolve the
 * transport that is live at call time ...
 */
/**
 * One `send` for the client's whole life, so the per-render dispatch does not
 * allocate a closure ...
 */
private readonly boundSend: SendFn = (message) => this.send(message);

private registryRoot: RegistryRoot = {
  root: () => this.wrapperElement,
  send: (message) => this.send(message),
};

The first comment describes registryRoot (mentions "accessors, not fields", live transport resolution) but sits above boundSend, while registryRoot itself is undocumented. Looks like a copy/paste ordering slip — worth moving the first block down to registryRoot. Also, boundSend and registryRoot.send are two separate closures doing the same (message) => this.send(message); could share one if that's not deliberate.

Nothing else stood out — the empty-value handling, duplicate-registration warnings, dispose/teardown symmetry, and late-registration catch-up all look correct and are covered by the test suite.

Review round 4 on #159, doc-only.

Inserting boundSend landed it between registryRoot's JSDoc and the field
it documents, so that block described the wrong member -- and it still
described `send` as an accessor resolving the transport, which stopped
being registryRoot's job when round 3 moved transport ownership onto the
per-element entry.

Each field now carries its own comment, and boundSend's says where the
reconnect guarantee actually comes from: it delegates to this.send,
which resolves the transport at call time, so a context holding it stays
correct without the registry re-keying anything.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
@adnaan

adnaan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Round 4 — nit fixed in 03496b5, doc-only.

You were right about the cause and there was a second half to it. Inserting boundSend landed it between registryRoot's JSDoc and the field that block documents, so it described the wrong member — and it still described send as an accessor resolving the live transport, which stopped being registryRoot's job when round 3 moved transport ownership onto the per-element entry. A comment that had drifted from the code twice over.

Each field carries its own now, and boundSend's says where the reconnect guarantee actually comes from: it delegates to this.send, which resolves the transport at call time, so a context holding it stays correct without the registry re-keying anything.

I grepped for other comments the round-3 change could have left stale (currentSend, "per-handler transport cache", "keyed only by the handler") — none remain.

Verification: 873 unit tests green, build clean. No behavioural change in this commit.


Thanks for four rounds of this. For the record, the seven defects found were all the same shape — the failure mode is silence — which is exactly what this registry exists to eliminate, and exactly what my own tests were structurally blind to: every one of them used the default domChanged = true, so the deferred-sweep bug could not surface. A category system whose whole purpose is skipping work needs its skip path tested, not just its run path. That's recorded as a feed-forward to Phase 3.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed the diff. Overall this is a well-designed, well-tested refactor (the registry migration, the declarative/low-level split, and the multi-client isolation via per-element ElementEntry are all solid, and the round-1/2/3 regressions from earlier review passes are covered by dedicated tests). Two things worth a look:

1. registerAttribute: duplicate-claim warning fires before shape/selector validation (attribute-registry.ts:342-350, vs. the rejection checks at :365-374 and :~457-474)

The "another handler already claims this name... Both will run" warning is emitted based on claimKey() matching an already-registered handler, but this check runs before the declarative-shape check (needs onElementAdded/onElement) and the selector-validity check. So if a second handler reuses an existing attribute name but is otherwise invalid (e.g. declares only onElementRemoved, or has an unusable attribute name), it gets:

  • a misleading "Both will run" warning (it won't — it's about to be rejected), followed by
  • the actual rejection warning.

Not a functional bug (the invalid handler is still correctly rejected), but it's confusing double-logging with an inaccurate message for third-party authors debugging a bad registerAttribute call. Moving the clash check after the validation checks (or wording it to not assert "both will run") would fix it.

2. dispose() cleanup isn't scoped to the disconnecting client (livetemplate-client.ts disconnect(), calling disposeHandlers(getRegisteredAttributes()))

dispose() is documented as "root-less cleanup... run on every disconnect" for handlers with module-global state (the built-in example is lvt-fx:auto-click's timer map). But the registry is module-level and shared across every LiveTemplateClient instance on a page, and disconnect() calls disposeHandlers() unconditionally for any client that disconnects — not gated on whether other clients are still live. With two clients on one page (a scenario this PR explicitly adds tests for, re: ctx.send/wrapperRoot isolation), disconnecting client A will tear down module-global state that client B still depends on.

This matches the pre-existing behavior for the one built-in that already had this shape (teardownAutoClickTimers() was previously called unconditionally on every disconnect too), so it's not a regression. But dispose() is now a public, documented extension point, and a third-party author relying on the multi-client story this PR advertises elsewhere could reasonably assume dispose() only runs when their handler's owning client goes away. Worth at least a doc callout, if not a fix (e.g. only running dispose() when liveRoots becomes empty).

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