Repository navigation
feat(attributes): make lvt-* attributes extensible via a handler registry - #159
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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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) <noreply@anthropic.com> 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 ( |
…ast client Review round 5 on #159. 1. The duplicate-claim warning ran before validation. It says "Both will run", which is only true of a handler that is about to be accepted -- but it was emitted before the declarative-shape and selector-validity checks. A handler that reused an existing name AND was invalid was told both would run, then immediately rejected. Moved to just before the handler is accepted, so it can only describe a handler that really will run. 2. dispose() tore down module-global state other clients were using. dispose() exists for state that has no root to be scoped to, which is precisely state that outlives any one client. The registry is module-level and shared by every client on the page, so running it on ANY client's disconnect meant client A's teardown pulled auto-click's timer map out from under client B. Gated on hasLiveRoots() now, checked after this client detaches, so the module registry's dispose runs when the LAST client goes away. instanceHandlers belong to this client and still dispose unconditionally. Behaviour is unchanged in every single-client path -- the one autoInit produces -- because detach immediately precedes the check, so the last client is also the only client. This was flagged in round 2 as matching pre-existing behaviour and deferred; that was the wrong call. It was true of the built-in, but dispose() is a NEW public extension point, and a third-party author reading the multi-client guarantees this PR makes elsewhere would reasonably expect it to follow their own client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 5 — both fixed in d5f68bc. 1. Clash warning ordering. Confirmed: it ran before the shape and selector checks, so a handler that reused a name and was invalid was told "Both will run" and then rejected. Moved to just before the handler is accepted, so the message can only ever describe a handler that really will run. Two tests pin it — the misleading pairing, and that a genuine clash between two valid handlers still warns. 2. I deferred this in round 2 on the grounds that it matched pre-existing behaviour. That was true of the built-in and beside the point: Fixed as you suggested — gated on Single-client behaviour is unchanged, which matters because that is the only path The reasoning generalises, and it is now on the interface where an author will read it: per-client state belongs in Verification: 877 unit tests green (4 new), lvt chromedp 19/19 against this build. |
|
Reviewed the diff (attribute-registry.ts, dom/builtin-handlers.ts, event-spec.ts, livetemplate-client.ts + tests). Overall this is well-executed: the registry/sweep/category split is sound, the two-client-on-one-page isolation (per-element A couple of non-blocking points worth a second look before this becomes a stable public API in Phase 3:
Nothing here blocks merging as Phase 1 — the built-in migration is behavior-preserving (824 unedited tests + E2E), and the new surface is heavily tested. |
Review round 6 on #159. No correctness findings; this is public-surface hygiene, done now because the surface is not published yet. `always` meant "every render even if a future empty-diff optimization skips other handlers". Nothing consumed it, updateDOM has no such skip to be exempt from, and so it was indistinguishable from `fire-on-change` in every observable way. Same reasoning that already removed `delegatedEvents` from this PR: adding a union member later is a minor release, removing one is a major, so unused surface does not get to ship first and be justified afterwards. Phase 3 adds it back if a consumer appears. Not marked breaking against a release -- HandlerCategory has never shipped -- but flagged so the type change is visible in the log. Also documents two limits the review asked to have confirmed as deliberate rather than overlooked: - No unregisterAttribute. Registration is append-only, which suits the documented <script defer> pattern but accumulates handlers under a hot-reloading dev server. Deferred because removal must sweep the handler's tracked elements and fire onElementRemoved, or the wired listeners outlive the handler -- the exact leak the declarative layer exists to prevent. That is a public-API design question and Phase 3 owns it. - The clash check folds case and the scan does not normalize it, and both are right for the same reason: HTML parsers ASCII-lowercase attribute names, and CSS attribute selectors match names ASCII-case-insensitively in HTML documents. Written down because both halves assume an HTML document, and neither assumption holds in XML or standalone SVG. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 6 — all three addressed in 3153070. 2. Cut 1. Deferred rather than added because it isn't the one-liner it looks like: removal has to sweep the handler's tracked elements and fire 3. Case-insensitivity comment — added, and it turned out to be worth more than a note about the clash check. The two halves rely on the same fact from opposite directions: the clash check folds case because HTML parsers ASCII-lowercase attribute names, and the scan never normalizes because CSS attribute selectors match names ASCII-case-insensitively in HTML documents. Both assume an HTML document, and neither holds in XML or standalone SVG — so that is what the comment says. Both limits are also in the CHANGELOG now, since they are things an author needs before building on this rather than after. Verification: 877 unit tests green, lvt chromedp 19/19 against this build. The type change is marked |
|
Reviewed the diff (
if (!el.getAttribute(attribute)) {
if (!warnedEmpty.has(el)) {
warnedEmpty.add(el);
logger.warn(...);
}
continue;
}
warnedEmpty.delete(el);
This isn't hit by the current tests — Suggest scoping the warned-set per handler, mirroring how Everything else looks in good shape — teardown/dispose ordering, the late-registration catch-up path, and the duplicate-registration diagnostics all read correctly against their stated invariants. |
…ement Review round 7 on #159. Same defect class as round 3, mirrored. warnedEmpty was a single module-level WeakSet<Element> shared by every declarative handler, but one element can carry two independently registered attributes -- <div lvt-x:copy="" lvt-x:rate="5"> -- so the handlers corrupted each other's bookkeeping about it, in both directions: - the populated handler's "this element is fine now" cleared the empty handler's "already warned", so the warning re-fired on every render: the per-keystroke spam the mechanism exists to prevent; - and two empty attributes on one element reported only the first, silently swallowing the second template bug. That direction was not in the report and is the worse one -- the mechanism turning a real template bug into silence is the failure mode this whole registry exists to eliminate. Keyed per handler, then per element now, mirroring `tracked`. Round 3 was this same mistake in the other axis: state keyed per handler when ownership was per element. Two instances make it a pattern rather than a slip, so the module's five pieces of state are now listed together with what each is keyed by and why -- a mismatch is much easier to see in one list than at five separate declarations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 7 — confirmed and fixed in 69838f1. Good catch, and it's the same defect class as round 3, mirrored: state keyed by something other than what owns the guarantee it backs. Round 3 was keyed per handler when ownership was per element; this was keyed per element when the guarantee is per handler-and-element. There is a second direction you didn't mention, and it's the worse one. Reproducing your case turned up both:
The second is the one that actually worried me: a diagnostic that turns a template bug into silence is the exact failure mode this registry exists to eliminate. Both directions are now pinned by tests. Keyed per handler, then per element, mirroring Because this is the second instance, I audited the rest. The module has five pieces of state; the other three are correctly keyed ( Verification: 879 unit tests green (2 new), lvt chromedp 19/19 against this build. |
|
Reviewed the full diff. This is a well-executed refactor — the built-in handler call order/gating (fire-on-change vs wire-idempotent) is faithfully preserved from the old hardcoded sequence in
Nit: the CHANGELOG says "824 pre-existing tests pass unmodified" while Nice work on the docs and the extensibility contract (declarative vs low-level split, live |
…e-register Review round 8 on #159. No correctness findings. 1. getRegisteredAttributes() handed out the live internal array. `readonly` is erased at compile time, so any caller could push into the registry or reorder it. Returning a copy would have fixed the exposure and reintroduced a per-render allocation on the hot path -- the render loop called it every render. Both, instead: the registry now iterates itself. runRegisteredHandlers / teardownRegisteredHandlers / disposeRegisteredHandlers walk the internal array directly, so the client never holds it and the render path allocates nothing; getRegisteredAttributes() returns a copy for inspection. Better encapsulation than the version that prompted it -- the client had no business fetching an array just to loop over it. 2. registerBuiltinHandlers() had no reentrancy guard. A page that evaluates the bundle twice -- duplicated <script>, bundler dedupe failure -- would register all eighteen built-ins again, and the six carrying needsServerChannel would dispatch every action to the server twice. The duplicate-name warning would fire eighteen times on the way, so not silent, but a doubled server action is not something to leave to a console warning. Guarded, and the test now asserts the guard rather than the warning it replaced. 3. A test comment cited "all 851" pre-existing tests; the real figure is 824 and 851 was the total including new ones. Cites no number now -- the suite grows, so any total written into a comment rots. The CHANGELOG's 824 is correct and stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 8 — all three addressed in 1c1b84c. 1. So: both, by removing the reason to hand the array out at all. The registry iterates itself now — 2. Double-registration guard. Agreed, and worth it for the consequence rather than the likelihood: the six 3. The count nit — you were right about the mismatch, and the 851 was the wrong one. 824 is the real pre-existing figure (the CHANGELOG's use is correct and stands); 851 was the total including new tests at the moment I wrote that comment. Rather than correct it to today's number, the comment now cites none — the suite grows, so any total written into prose rots. That's what produced the mismatch in the first place. Verification: 879 unit tests green, lvt chromedp 19/19 against this build. |
|
Reviewed the diff (attribute-registry.ts, dom/builtin-handlers.ts, event-spec.ts, livetemplate-client.ts changes). Overall this is a solid, carefully-reasoned refactor — the design docs in the file headers are unusually thorough, and the test suite explicitly pins the multi-client transport isolation, sweep-vs-category gating, and empty-value warning scoping bugs that earlier review rounds apparently caught. I traced those fixes through the code and they check out. A couple of things worth a second look, not blockers:
Nothing else jumped out — error isolation (try/catch around every third-party hook), the live-accessor |
Review round 9 on #159 asked whether the unauthenticated public entry point is the intended threat model. It is, and the reason is worth having in the file rather than in a PR thread, because the next reader will ask the same question. registerAttribute grants no capability a page script did not already have. autoInit assigns the live client to window.liveTemplateClient and send() is public, so any script with page access could already dispatch arbitrary actions -- or click a real lvt-on: element, or POST to the live endpoint with the session cookie. The boundary that matters is the server's and it has not moved: actions arriving through a registered handler are still just actions, subject to whatever authorization the handler applies. A same-origin script is fully trusted by the browser's own model, so a page running untrusted script is already compromised in ways no client-side check could fix. Also in the CHANGELOG, since it is something to know before building on the API rather than after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 9 — threat model confirmed and written down in 1046f40. The other two are already-settled or declined, with reasons. 1. Security surface — yes, intended, and it grants nothing new. I checked before answering rather than asserting it: The boundary that matters is the server's, and it hasn't moved: actions arriving through a registered handler are still just actions, subject to whatever authorization the handler applies. A same-origin script is fully trusted by the browser's own model, so a page running untrusted script is already compromised in ways no client-side check could fix. That reasoning is now in the file above 2. 3. O(n) duplicate check — declining, deliberately. You're right that it's not O(1). At ~20 handlers the scan is unmeasurable next to the Verification: 879 unit tests green, build clean. No behavioural change in this commit. On the earlier |
|
Reviewed the diff (attribute-registry.ts, dom/builtin-handlers.ts, event-spec.ts, and the livetemplate-client.ts wiring). Overall this is a well-executed refactor — genuinely extensible, and it clearly went through several self-review rounds already (the test files literally cite fixes for "PR #159, round 2/3/5/7": the sweep-vs-scan gating bug, the cross-client transport leak, the per-handler empty-value warning state, and the dispose-scoping-to-last-client fix). I traced through those scenarios and they hold up — didn't find a way to reintroduce any of them. A few things I verified specifically and found correct:
Minor, non-blocking items worth a second look:
No correctness or security issues found. The unauthenticated |
Review round 10 on #159 asked whether registerBuiltinHandlers() running at module load with no `typeof window` guard -- unlike autoInit() -- is intentional. It is, and the reason is that registration is meant to be DOM-free: the built-ins are all low-level, so registering them touches nothing. The selector-validity probe was the one exception. It calls document.createDocumentFragment(), so a DECLARATIVE registration from an SSR or tooling context would have thrown on a module whose import is otherwise harmless. Skipped when there is no document now; the check still runs for every registration that happens in a browser, which is every registration that can reach a render. Tested in a real `node` jest environment rather than by deleting globalThis.document in the jsdom suite. The first version of this test did the latter and passed against the unfixed code: jsdom defines document as NON-CONFIGURABLE, so the delete silently no-ops and the test never simulated the thing it claimed to. The node-environment file has genuinely never had a document, and it fails against the unguarded version -- verified by reverting the guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 10 — the module-load question was worth asking, and checking it found a real gap. Fixed in c863e8c. Your reasoning was right and the conclusion was slightly wrong. But the selector-validity probe I added in round 2 was the one exception — it calls The test for it is the part worth flagging. My first version deleted That's the third false-pass this PR has produced, all the same shape — a test that agrees with me for a reason unrelated to what it claims to check. Worth stating since the earlier rounds' fixes rest on tests written the same way; these are the ones I've since verified by reverting the fix. On Verification: 883 unit tests green (4 new, in a new node-environment suite), lvt chromedp 19/19 against this build. |
|
Reviewed the diff (registry core, built-in handler migration, and the new test suites). Correctness check performed: I traced every built-in handler's derived No bugs found. A couple of very minor, non-blocking observations:
Nice attention to the tricky parts: the live-accessor |
Review round 11 on #159. No bugs found; these close the last two gaps. 1. A nameless low-level handler was invisible to the duplicate warning. claimKey() returns null without a name, and the warning deliberately never keys on `selectors` (descriptive, and two built-ins legitimately claim a bare "*" walk). So two unnamed low-level handlers claiming overlapping DOM clashed silently -- the one outcome these diagnostics exist to prevent. BaseHandler.name already documented itself as "required for low-level" while the type said optional. LowLevelHandler now narrows it to required, so the type matches its own documentation, and registration rejects a nameless one at runtime too: this API is reachable from a plain <script>, where no type ever ran. Not breaking against a release -- the interface has never shipped -- but marked so the type change is visible in the log. 2. boundSend and registryRoot.send were two identical closures. registryRoot now reuses boundSend. Field initializers run in declaration order so it is already assigned, and it is readonly, so it cannot go stale. Cosmetic; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
|
Round 11 — both closed in 767b61f. Thanks for tracing all 18 categories against the old sequence; that's the check I most wanted an independent pass on. 2 (the one that mattered). You're right that it's called out as intentional, but following it through, the comment was defending the wrong half. Keying the warning off And
1. Verification: 885 unit tests green (2 new), lvt chromedp 19/19 against this build. A note on this PR's review cadence, and a suggestion.
Rounds 9, 10 and 11 all opened with no correctness findings; the last four items were a threat-model question, an O(n) note I declined, a narrow SSR gap, and this naming contract. That reads like a PR that is done being reviewed by a process that can no longer hold all of it at once — and every further round grows the diff that causes the failures. Suggest merging Phase 1 on this basis rather than continuing, and keeping Phase 3 to its own PR. |
scripts/release.sh aborted on this test, which turned out to be right:
the test read dist/ but `npm test` runs BEFORE `npm run build` in both
CI and the release script, so it had three behaviours depending on
ambient state --
- dist/ absent (CI): fs.existsSync false -> it.skip. It never ran
there, so the IIFE-global finding it exists to pin was unverified by
every CI run on #159.
- dist/ stale (a checkout that has not rebuilt): ran against an old
bundle and failed. This is what stopped the release.
- dist/ fresh: passed. The rarest case, and the only one I had.
A test whose most common outcome is a silent skip pins nothing. It now
builds the bundle in-process with esbuild, so the assertion is
deterministic and always runs, and it asserts that package.json's
build:browser still passes the flags this build mirrors -- otherwise the
in-test bundle could drift from the shipped one and quietly stop
representing it.
Runs under the `node` jest environment: esbuild refuses to run in jsdom,
whose Buffer comes from another realm. That is a bonus rather than a
workaround -- evaluating the bundle there also proves it loads in a
non-browser context, which autoInit's `typeof window` guard and DOM-free
registration are what make possible.
The .d.ts probe became a source-level re-export check, for the same
reason: what lands in dist/*.d.ts is decided by `declaration: true` plus
that re-export, and checking the re-export needs no build.
Falsified by removing the named export: 2 of 3 fail, restore, 3 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6hr7ZCG9o4pCmtnA8qhSq
The client's registerAttribute() API (livetemplate/client#159, shipped in @livetemplate/client@0.25.1) has a failure mode of SILENCE: a handler that never runs, or one that warns on every page load, breaks nothing an existing assertion looks at. The existing suites exercise attribute BEHAVIOUR through the built-ins, not the wiring, so they cannot catch either. Asserts the two things only a browser can show: 1. A page using lvt-* attributes produces no registry diagnostics -- the registry must not warn about the framework's own handlers. 2. A handler registered from a plain <script> AFTER the client tag takes effect immediately, against the DOM already on screen. That is the only path a third-party bundle has: under the documented `defer` pattern the core bundle has auto-initialized and connected before any second script evaluates. It also pins the global's spelling. --global-name over a module that also exports a class of the same name makes window.LiveTemplateClient the module NAMESPACE, so LiveTemplateClient.registerAttribute -- what every doc example writes -- resolves to a named export rather than the class static. Falsified before being trusted: against the pre-0.25.1 bundle it fails with "registerAttribute is not a function"; against 0.25.1 it passes. The console assertion is scoped to this feature's own diagnostics rather than demanding total silence -- the fixture serves no WebSocket endpoint, so the client legitimately reports a transport failure, and asserting on silence would make the test hostage to unrelated noise.
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