fix(csr,ui): deliver component outputs to parent bindings
Quality / quality (ubuntu-latest) (push) Failing after 13m39s
Quality / quality (windows-latest) (push) Canceled after 0s

An output only reaches a parent @binding when the component calls
output.<name>(). Two separate faults meant most of the library never got
there, and both failed silently at each end.

HTML lowercases attribute names, so a parent's @sizeChange registered under
"sizechange" while the component emitted "sizeChange". The lookup missed, fell
through to a DOM dispatch, and the binding was never invoked. That made all 17
camelCase outputs undeliverable -- DataTable.pageChange and .rowClick,
Map.markerClick, ChatBubble.messageClick, LayoutSplitter.sizeChange and the
rest. invokeComponentOutput now falls back to a case-insensitive lookup, and a
csr test fails without it.

Separately, 18 components dispatched hand-built CustomEvents rather than
calling output.*. A bubbling event on the component's own root never reaches a
binding, because parent handlers live in a registry only the output proxy
reads. Card, Footer, Breadcrumb, Accordion, alert, Badge, AnnouncementBar,
AvatarGroup, ToggleCount and InputNumber now emit properly; Marquee, Map,
Timeline, List and SearchBox additionally declare the outputs they were
already firing. Dispatches on window are left alone -- that is how Toaster,
Modal and DataTable signal across component boundaries.

Verified in a browser both ways before and after: an AnnouncementBar
dispatching its own bubbling "dismiss" never reached a page-level @dismiss,
and reached it immediately once it called output.dismiss().

This corrects the audit, which called the LayoutSplitter failure "narrow and
unexplained" and read 32 dead outputs as 16 components needing a rebuild.
"Outputs work elsewhere" was an assumption; the components that worked
happened to use lowercase names and output.*. The dead-output ratchet drops
from 32 to 22, and a new test forbids the raw-CustomEvent pattern outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 01:40:39 +05:30
co-authored by Claude Opus 5
parent 84dc4e06a2
commit 5e65627305
24 changed files with 384 additions and 224 deletions
+59 -8
View File
@@ -3103,14 +3103,18 @@ test("every wire color token a component references is defined by the theme", ()
test("no component gains an output that nothing ever emits", () => {
/*
* LayoutSplitter declared resizeStart, resize and resizeEnd with no pointer
* handling at all: a caller wired up @resize and received nothing, for ever,
* with no error. The same shape survives in other components, so this pins
* the count rather than letting it grow while the rest are rebuilt.
* An output only reaches a parent @binding when the component calls
* output.<name>(). The runtime keeps parent handlers in a registry that only
* invokeComponentOutput reads, so a component that instead dispatches its own
* CustomEvent -- even a bubbling one, on its own root -- is emitting into
* nothing: the parent binding is never invoked and no error is raised.
* Eighteen components did exactly that and were converted; this pins what is
* left, which are components with no emitter of any kind.
*
* Native event names are excluded. The runtime binds a DOM-listener fallback
* on component tags, so declaring click or input as an output does reach a
* parent binding through bubbling.
* Native event names are excluded, and that exclusion is real rather than
* assumed: invokeComponentOutput falls back to dispatchComponentEvent when no
* handler is registered, and a parent @click on a component tag is also bound
* as an ordinary DOM listener, so a natively-named output does arrive.
*/
const native = new Set([
"click",
@@ -3160,7 +3164,54 @@ test("no component gains an output that nothing ever emits", () => {
* A ceiling, not a target. It only ever moves down: rebuilding one of these
* components should tighten it.
*/
expect(offenders.length).toBeLessThanOrEqual(32);
expect(offenders.length).toBeLessThanOrEqual(22);
expect(offenders).not.toContain("LayoutSplitter.sizeChange");
expect(offenders).not.toContain("CustomScrollbar.scroll");
});
test("a declared output is never emitted as a hand-built CustomEvent", () => {
/*
* The failure this prevents is silent in both directions: the component
* looks like it emits, the caller looks like it listens, and the event
* bubbles right past the binding because the runtime resolves parent
* handlers from a registry rather than from the DOM. Verified in a browser
* before this test was written -- an AnnouncementBar dispatching its own
* bubbling "dismiss" never reached a page-level @dismiss, and the same
* component reached it immediately once it called output.dismiss().
*
* Dispatching on window is a different thing and stays allowed: that is how
* Toaster, Modal and DataTable signal across component boundaries, where
* there is no parent binding to reach.
*/
const offenders: string[] = [];
for (const name of uiComponentNames()) {
const source = readFileSync(uiComponentPath(name), "utf8");
const block = /^ {2}outputs \{([\s\S]*?)^ {2}\}/m.exec(source);
if (!block) continue;
const declared = new Set(
[...block[1]!.matchAll(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*\(/gm)].map((m) => m[1]!),
);
for (const match of source.matchAll(
/(\w+(?:\.\w+)*)\.dispatchEvent\(|initCustomEvent\(\s*"([A-Za-z][A-Za-z0-9_]*)"/g,
)) {
const target = match[1];
if (target && /^window\b/.test(target)) continue;
// Which event name is being built here?
const around = source.slice(Math.max(0, match.index - 400), match.index + 200);
for (const declaredName of declared) {
const quoted = `"${declaredName}"`;
if (
around.includes(`CustomEvent(${quoted}`) ||
around.includes(`initCustomEvent(${quoted}`)
) {
offenders.push(`${name}.${declaredName}`);
}
}
}
}
expect([...new Set(offenders)]).toEqual([]);
});