fix(csr,ui): deliver component outputs to parent bindings
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:
@@ -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([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user