feat(attributes): make lvt-* attributes extensible via a handler registry - #159
feat(attributes): make lvt-* attributes extensible via a handler registry#159adnaan wants to merge 5 commits into
Conversation
…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
|
Paired browser coverage: livetemplate/lvt#344 (draft — it asserts this PR's API, so it stays red on lvt's Release order per the plan's cross-repo protocol: this PR → client release → un-draft and merge livetemplate/lvt#344. |
|
Reviewed the attribute-registry refactor. Solid design overall (live accessors for 1.
But
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 (
Nothing else stood out — the WeakMap-keyed tracking, transport-following |
…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
|
Both review findings were real and are fixed in cf8a32c. Reproduced each with a failing test first. 1. You flagged that a And it lands on the most natural shape there is: Fix: the sweep and the scan gate differently now, because they have opposite cost profiles and opposite failure modes.
The category never meant "skip cleanup". My test for this passed because it used the default 2. 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 For the selector I took the other end instead of wrapping the call. You're right that Verification: 865 unit tests green (4 new regression tests), and lvt's chromedp suite re-run against this build — 19/19. |
|
Reviewed the diff. Solid design overall (the live-accessor
const wantsAdded = !!handler.onElementAdded;
const wantsEvery = !!handler.onElement;
if (!wantsAdded && !wantsEvery) return;Since elements are only added to the per-handler 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 Note, not a blocker: Nothing else stood out — error isolation around third-party |
…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
|
Round 2 — both findings fixed in df05809, and the first one surfaced a third problem underneath it. 1. 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 2. Empty-value warning spam. Fixed with a 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 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 On the Verification: 870 unit tests green (5 new), lvt chromedp 19/19 against this build. |
|
Reviewed the diff ( Cross-client contamination of
const currentSend = new WeakMap<AttributeHandler, SendFn>();
const currentRoots = new WeakMap<AttributeHandler, { scanRoot: Element; wrapperRoot: Element }>();
if (channel && currentSend.get(handler) !== channel) currentSend.set(handler, channel);
...
currentRoots.set(handler, roots);The registry itself is explicitly shared/module-level ( That's exactly the failure mode The new |
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
|
Round 3 — confirmed and fixed in fff48e3. This one is squarely my bug: unlike the Reproduced with three tests before touching the fix: two clients, both with elements matching 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 They are still refreshed on every dispatch rather than captured at creation, which preserves the reconnect guarantee This also retires the round-2 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 |
|
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 One minor nit, not blocking: livetemplate-client.ts — misplaced/duplicated JSDoc block. Around the /**
* 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 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
|
Round 4 — nit fixed in 03496b5, doc-only. You were right about the cause and there was a second half to it. Inserting Each field carries its own now, and I grepped for other comments the round-3 change could have left stale ( 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 |
|
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 1. The "another handler already claims this name... Both will run" warning is emitted based on
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 2.
This matches the pre-existing behavior for the one built-in that already had this shape ( |
Phase 1 of livetemplate/livetemplate#473. The governing plan is
docs/plans/issue-473.mdin the livetemplate repo.Why
Adding an
lvt-*attribute meant forking this library: write adom/*.tsmodule, import it intolivetemplate-client.ts, insert a call at the right position in a hardcoded ~20-entry post-render sequence, and add a matching entry todisconnect()'s teardown list. An app that wantedlvt-x:copy-to-clipboardhad 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:
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.registerAttributeis a module-level named export, mirrored as a class static.--global-name=LiveTemplateClientover a module that also exports a class of that name makeswindow.LiveTemplateClientthe 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
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=1is mandatory there — without itgo testreplays 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.sendwas stale-by-construction. The first implementation builtElementContextwith a getter closing over the dispatch parameter — still a capture, becauseonElementAddedfires 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 aWeakMapthe accessor reads. Benign in production today (the client'ssendis 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
tscwas 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.tsnow pins the set, the order, the sixneedsServerChannelhandlers, the singlewire-idempotententry and the lonedispose— 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.attributescensus 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
url-hashandinitializeFileInputs(#453) both arm at connect for the identical reason — a page load produces noupdateDOMcall — and any third-party handler over SSR'd markup needs the same. An opt-inrunOnConnectwould express it. Not done here because it moves those calls relative to the rest ofconnect(), and this phase's bar is that nothing changes behaviour.alwaysearns its place. Behaviourally identical tofire-on-changetoday, no consumer. Kept because the plan's L3 decided three categories, but if Phase 3 still has no consumer, drop it.delegatedEventswas removed on exactly this reasoning — public surface with no consumer — and Phase 3 adds it when it consumes it.🤖 Generated with Claude Code
https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq