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
+27 -1
View File
@@ -2033,11 +2033,37 @@ const MIGRATIONS: Migration[] = [
version: "0.8.6",
id: "0.8.6-navigation-and-layout-groups",
description:
"Rebuilds the navigation and layout component groups, moves them off Tailwind utilities onto wire-* classes, and defines theme tokens that components referenced but nothing declared.",
"Rebuilds the navigation and layout component groups, moves them off Tailwind utilities onto wire-* classes, defines theme tokens that components referenced but nothing declared, and makes component outputs actually reach parent bindings.",
apply() {
// Source changes no codemod can make safely, so they are listed rather
// than attempted.
//
// OUTPUTS NOW ARRIVE. Two faults kept declared outputs from reaching a
// parent @binding, and both are fixed. Expect handlers that never ran
// before to start running -- this is the intended repair, but it is a
// behaviour change in code you may have written around.
//
// 1. Every camelCase output was undeliverable. HTML lowercases
// attribute names, so @sizeChange registered as "sizechange" while
// the component emitted "sizeChange" and the lookup missed. That
// covered all 17 camelCase outputs, including DataTable.pageChange
// and .rowClick, Map.markerClick, ChatBubble.messageClick and
// LayoutSplitter.sizeChange. The runtime now matches case
// insensitively.
//
// 2. Eighteen components dispatched hand-built CustomEvents instead of
// calling output.*, which never reaches a binding. Card, Footer,
// Breadcrumb, Accordion, alert, Badge, AnnouncementBar, AvatarGroup,
// ToggleCount and InputNumber now emit properly.
//
// If you worked around the old silence by listening for the raw DOM
// event on the element, that listener still fires for cases where no
// binding is registered, but the supported route is the @binding.
//
// Marquee, Map, Timeline, List and SearchBox now DECLARE the outputs
// they were already firing: pause/resume, markerClick/select/zoom,
// select, select and search/clear respectively.
//
// Tabs replaced its raw CustomEvents with declared outputs. Code
// listening for the old change and select events on the element must
// move to the @change and @select bindings.
+19
View File
@@ -3927,6 +3927,25 @@ export const REACTIVE_RUNTIME = String.raw`
if (!root || !name) return undefined;
var registry = root.__wrnexusOutputHandlers;
var handlers = registry && registry[name];
/*
* A parent writes @sizeChange, but HTML lowercases attribute names, so the
* handler is registered under "sizechange" while the component emits
* "sizeChange". Without this the lookup misses, the call falls through to
* dispatchComponentEvent, and the binding is never invoked -- silently.
* Every camelCase output in the library was undeliverable because of it.
*/
if ((!handlers || !handlers.size) && registry) {
var lower = String(name).toLowerCase();
if (lower !== name) handlers = registry[lower];
if (!handlers || !handlers.size) {
for (var key in registry) {
if (key.toLowerCase() === lower && registry[key] && registry[key].size) {
handlers = registry[key];
break;
}
}
}
}
if (handlers && handlers.size) {
var values = [];
handlers.forEach(function (handler) { values.push(handler(payload)); });
+32
View File
@@ -631,6 +631,38 @@ test("component output handlers run in the parent scope", () => {
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});
test("a camelCase output reaches a parent binding despite attribute lowercasing", () => {
/*
* A parent writes @sizeChange; HTML lowercases attribute names, so the
* handler registers under "sizechange" while the component emits
* "sizeChange". The lookup used to miss and fall through to a DOM dispatch,
* so the binding was never invoked and nothing reported an error. Every
* camelCase output in the library was undeliverable -- LayoutSplitter's
* sizeChange, DataTable's pageChange and rowClick, Map's markerClick and
* twelve more. Verified in a browser before and after the fix.
*/
const win = mount(
`<div data-scope="saved: ''">` +
`<span id="out">{saved}</span>` +
`<div data-scope="n: 0" data-wrn-hydration="LayoutSplitter:x">` +
`<div data-wrn-events="sizechange" data-wrn-out-sizechange="saved = 'yes'">` +
`<button data-on-click="output.sizeChange({ size: 45 })">go</button>` +
`</div></div></div>`,
);
const target = win.document.querySelector("[data-wrn-events]") as unknown as {
__wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
};
// The registry is keyed as the DOM gave it: lowercased, not as authored.
expect(Object.keys(target.__wrnexusOutputHandlers ?? {})).toContain("sizechange");
expect(target.__wrnexusOutputHandlers?.sizeChange).toBeUndefined();
// Emitting through the real output proxy, under the camelCase name the
// component actually writes, must still reach the parent.
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});
// --- browser globals + regex literals in client expressions ----------------
// Client functions and inline handlers are interpreted by the runtime's own
// eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
+5 -5
View File
@@ -229,7 +229,7 @@ Present structured responsive linked or status items with icons, descriptions, a
- Mount: `data-component="List"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default`
- Outputs: None
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### ListGroup
@@ -247,7 +247,7 @@ Continuously present responsive labels, partners, notices, or capabilities with
- Mount: `data-component="Marquee"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default`
- Outputs: None
- Outputs: `pause({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `resume({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Progress
@@ -301,7 +301,7 @@ Present responsive chronological activity, milestones, or workflow status with r
- Mount: `data-component="Timeline"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default`
- Outputs: None
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Toast
@@ -469,7 +469,7 @@ Provide an accessible responsive search field with labels, validation states, si
- Mount: `data-component="SearchBox"`
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Search Box"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "search"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
- Slots: None
- Outputs: None
- Outputs: `search({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `clear({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### Select
@@ -588,7 +588,7 @@ Present responsive location information and markers with map-ready metadata and
- Mount: `data-component="Map"`
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
- Slots: `default`
- Outputs: None
- Outputs: `markerClick({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `zoom({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
### ToastNotifications
+51 -10
View File
@@ -8064,8 +8064,13 @@
}
],
"slots": ["default"],
"outputs": [],
"events": [],
"outputs": [
{
"name": "select",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["select"],
"source": "components/List.wrn"
},
{
@@ -8195,8 +8200,21 @@
}
],
"slots": ["default"],
"outputs": [],
"events": [],
"outputs": [
{
"name": "markerClick",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
},
{
"name": "select",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
},
{
"name": "zoom",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["markerClick", "select", "zoom"],
"source": "components/Map.wrn"
},
{
@@ -8352,8 +8370,17 @@
}
],
"slots": ["default"],
"outputs": [],
"events": [],
"outputs": [
{
"name": "pause",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
},
{
"name": "resume",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["pause", "resume"],
"source": "components/Marquee.wrn"
},
{
@@ -10911,8 +10938,17 @@
}
],
"slots": [],
"outputs": [],
"events": [],
"outputs": [
{
"name": "search",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
},
{
"name": "clear",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["search", "clear"],
"source": "components/SearchBox.wrn"
},
{
@@ -13034,8 +13070,13 @@
}
],
"slots": ["default"],
"outputs": [],
"events": [],
"outputs": [
{
"name": "select",
"payloadType": "{ sourceEvent?: Event; [key: string]: string | number | boolean | null | object }"
}
],
"events": ["select"],
"source": "components/Timeline.wrn"
},
{
+13 -11
View File
@@ -49,22 +49,24 @@ component Accordion {
return multiple || alwaysOpen
}
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]")
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
// Named rather than computed: an output is resolved as a property name, so
// output[eventName] would not reach a parent binding.
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, payload) {
payload = {
component: "Accordion",
value: value,
item: item,
open: isOpen(value),
openValues: openValues
})
root.dispatchEvent(customEvent)
}
if (eventName === "open") {
output.open(payload)
} else if (eventName === "close") {
output.close(payload)
} else {
output.change(payload)
}
}
client function toggleItem(sourceEvent, value, item, wasOpen) {
+1 -1
View File
@@ -127,7 +127,7 @@ badge: string = ""
aria-label='{dismissLabel}'
title='{dismissLabel}'
class="wire-announcement__dismiss"
@click='dismissed = true; event.currentTarget.dispatchEvent(new CustomEvent("dismiss", { bubbles: true, detail: { message: message } }))'
@click='dismissed = true; output.dismiss({ message: message })'
>
<span
class="icon-[lucide--x]"
+2 -6
View File
@@ -30,17 +30,13 @@ component AvatarGroup {
return items.slice(Number(maxVisible))
}
client function toggleOverflow(sourceEvent, root, customEvent) {
client function toggleOverflow() {
overflowOpen = !overflowOpen
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]")
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("overflow", true, false, {
output.overflow({
component: "AvatarGroup",
open: overflowOpen,
hiddenCount: hiddenMembers().length
})
root.dispatchEvent(customEvent)
}
}
+2 -2
View File
@@ -34,7 +34,7 @@ label: string = "Breadcrumb"
<a
href='{homeHref || "/"}'
class="wire-breadcrumb__link wire-breadcrumb__home"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 } }))'
@click='output.select({ item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 })'
>
{#if homeIcon === "icon-[lucide--house]"}
<span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span>
@@ -72,7 +72,7 @@ label: string = "Breadcrumb"
target='{item.target || ""}'
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
class="wire-breadcrumb__link"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))'
@click='output.select({ item: item, itemIndex: itemIndex })'
>
{#if item.icon}
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
+3 -3
View File
@@ -79,7 +79,7 @@ component Footer {
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
aria-current='{child.active ? "page" : ""}'
class="wire-footer__link"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } })); child.action && event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } }))'
@click='output.select({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex }); child.action && output.action({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex })'
>
{#if child.icon}
<span
@@ -127,7 +127,7 @@ component Footer {
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
aria-current='{item.active ? "page" : ""}'
class="wire-footer__link"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } })); item.action && event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))'
@click='output.select({ item: item, itemIndex: itemIndex }); item.action && output.action({ item: item, itemIndex: itemIndex })'
>
{#if item.icon}
<span
@@ -199,7 +199,7 @@ component Footer {
rel='{child.external ? "noopener noreferrer" : (child.rel || "")}'
aria-current='{child.active ? "page" : ""}'
class="wire-footer__link"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } })); child.action && event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex } }))'
@click='output.select({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex }); child.action && output.action({ item: child, parent: item, itemIndex: childIndex, sectionIndex: itemIndex })'
>
{#if child.icon}
<span
+16 -16
View File
@@ -232,26 +232,17 @@ component InputNumber {
)
}
// Outputs are resolved by name, so each one is written out. Raw
// CustomEvents dispatched on the root -- what this did before -- never
// reach a parent @binding.
client function dispatchInputNumberEvent(
sourceEvent,
eventName,
action,
previousValue,
root,
customEvent
payload
) {
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
if (!root && sourceEvent.target) {
root = sourceEvent.target.closest("[data-wrn-input-number]")
}
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
payload = {
component: "InputNumber",
name: name,
value: currentValue,
@@ -261,8 +252,17 @@ component InputNumber {
max: hasMax() ? Number(max) : null,
step: normalizedStep(),
valid: !isInvalid()
})
root.dispatchEvent(customEvent)
}
if (eventName === "input") {
output.input(payload)
} else if (eventName === "change") {
output.change(payload)
} else if (eventName === "increment") {
output.increment(payload)
} else if (eventName === "decrement") {
output.decrement(payload)
}
}
client function applyControlValue(nextValue, action, sourceEvent, previousValue) {
+4 -3
View File
@@ -18,9 +18,10 @@
// and silently swallows the rule that follows it.
component LayoutSplitter {
outputs {
// Not named resize. An output named after a native DOM event never reaches
// a parent binding: the component emits it, but @resize on the tag is
// never invoked. sizeChange is unambiguous and does arrive.
// Named sizeChange rather than resize so it cannot be confused with the
// native window event a caller may already be listening for. An earlier
// comment here claimed a natively-named output could never reach a parent
// binding; that was wrong, and the rename was never what fixed anything.
sizeChange(payload: { size: number })
}
+5 -1
View File
@@ -1,4 +1,8 @@
component List {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
@@ -52,7 +56,7 @@ component List {
class:py-2.5='size === "sm"'
class:px-5='size === "lg"'
class:py-4='size === "lg"'
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
@click='output.select({ item: item, index: index })'
>
{#if item.icon}
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
+11 -3
View File
@@ -1,4 +1,12 @@
component Map {
outputs {
// markerClick and select both fire for a marker press; select is the
// generic name callers reach for, markerClick the explicit one.
markerClick(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
zoom(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
@@ -54,7 +62,7 @@ component Map {
aria-label='{item.label || item.title || "Map marker"}'
class="absolute inline-flex size-10 items-center justify-center rounded-full border-4 border-[var(--wire-color-surface-raised)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-lg transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
@click='event.currentTarget.dispatchEvent(new CustomEvent("markerClick", { bubbles: true, detail: item })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: item }))'
@click='output.markerClick(item); output.select(item)'
>
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
</button>
@@ -66,7 +74,7 @@ component Map {
type="button"
aria-label="Zoom in"
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "in" } }))'
@click='output.zoom({ direction: "in" })'
>
<span class="icon-[lucide--plus] size-4" aria-hidden="true"></span>
</button>
@@ -74,7 +82,7 @@ component Map {
type="button"
aria-label="Zoom out"
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "out" } }))'
@click='output.zoom({ direction: "out" })'
>
<span class="icon-[lucide--minus] size-4" aria-hidden="true"></span>
</button>
+9 -3
View File
@@ -1,4 +1,10 @@
component Marquee {
outputs {
// Fired when the reader pauses the scroll, by hover, focus or the button.
pause(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
resume(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
@@ -27,8 +33,8 @@ component Marquee {
<div
class="group relative min-w-0 flex-1 overflow-hidden"
@mouseenter='paused = true; event.currentTarget.dispatchEvent(new CustomEvent("pause", { bubbles: true }))'
@mouseleave='paused = false; event.currentTarget.dispatchEvent(new CustomEvent("resume", { bubbles: true }))'
@mouseenter='paused = true; output.pause({})'
@mouseleave='paused = false; output.resume({})'
@focusin='paused = true'
@focusout='paused = false'
>
@@ -77,7 +83,7 @@ component Marquee {
type="button"
aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
@click='paused = !paused; event.currentTarget.dispatchEvent(new CustomEvent(paused ? "pause" : "resume", { bubbles: true }))'
@click='paused = !paused; paused ? output.pause({}) : output.resume({})'
>
<span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span>
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
+7 -2
View File
@@ -1,4 +1,9 @@
component SearchBox {
outputs {
search(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
clear(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
@@ -22,7 +27,7 @@ component SearchBox {
data-ui-component="SearchBox"
role="search"
class='w-full {class}'
@submit='event.preventDefault(); event.currentTarget.dispatchEvent(new CustomEvent("search", { bubbles: true, detail: { value: query, name: name } }))'
@submit='event.preventDefault(); output.search({ value: query, name: name })'
>
<label
class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]"
@@ -61,7 +66,7 @@ component SearchBox {
aria-label="Clear search"
data-show='query.length > 0 && !disabled'
class="absolute right-12 inline-flex size-8 items-center justify-center rounded-lg text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); event.currentTarget.dispatchEvent(new CustomEvent("clear", { bubbles: true, detail: { value: "", name: name } }))'
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); output.clear({ value: "", name: name })'
>
<span class="icon-[lucide--x] size-4" aria-hidden="true"></span>
</button>
+5 -1
View File
@@ -1,4 +1,8 @@
component Timeline {
outputs {
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props {
size: string = "default"
color: string = "primary"
@@ -30,7 +34,7 @@ component Timeline {
{#each items as item, index}
<li
class="relative pb-8 pl-8 last:pb-0"
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
@click='output.select({ item: item, index: index })'
>
<span
class="absolute -left-[1.05rem] top-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--wire-color-background)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-sm"
+10 -27
View File
@@ -57,34 +57,17 @@ component ToggleCount {
: emptyValue
}
client function dispatchToggleEvent(sourceEvent, previousValue, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-toggle-count]")
if (!root) {
return
client function dispatchToggleEvent(sourceEvent, previousValue, payload) {
payload = {
component: "ToggleCount",
name: name,
value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("change", true, false, {
component: "ToggleCount",
name: name,
value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
})
root.dispatchEvent(customEvent)
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("toggle", true, false, {
component: "ToggleCount",
name: name,
value: selectedValue,
previousValue: previousValue,
firstValue: firstValue,
secondValue: secondValue
})
root.dispatchEvent(customEvent)
output.change(payload)
output.toggle(payload)
}
client function selectValue(sourceEvent, nextValue, previousValue) {
+12 -11
View File
@@ -35,22 +35,23 @@ component Alert {
state visible: boolean = true
functions {
client function dispatchAlertEvent(sourceEvent, eventName, action, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-alert]")
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent(eventName, true, false, {
// The output name has to be written out rather than computed: outputs are
// resolved as named properties, so a dynamic key would not reach a parent
// binding. Only two names exist here, so a branch is honest and cheap.
client function dispatchAlertEvent(sourceEvent, eventName, action, payload) {
payload = {
component: "Alert",
title: title,
color: color,
variant: variant,
action: action
})
root.dispatchEvent(customEvent)
}
if (eventName === "dismiss") {
output.dismiss(payload)
} else {
output.action(payload)
}
}
client function dismissAlert(sourceEvent) {
+6 -8
View File
@@ -35,15 +35,13 @@ component Badge {
state visible: boolean = true
functions {
client function dismissBadge(sourceEvent, root, customEvent) {
// output.dismiss reaches a parent @dismiss binding; a raw dispatchEvent on
// the root does not. The runtime registers parent handlers in a registry
// that only the output proxy consults, so the CustomEvent this used to
// build bubbled past every binding and was never seen by anyone.
client function dismissBadge() {
visible = false
root = sourceEvent.currentTarget.closest("[data-wrn-badge]")
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("dismiss", true, false, {
component: "Badge",
label: label
})
root.dispatchEvent(customEvent)
output.dismiss({ component: "Badge", label: label })
}
}
+21 -61
View File
@@ -45,67 +45,27 @@ component Card {
state dismissed: boolean = false
functions {
client function dispatchCardNavigation(sourceEvent, item, index, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("navigate", true, false, {
component: "Card",
item: item,
index: index
})
root.dispatchEvent(customEvent)
// Outputs must go through output.*; a CustomEvent dispatched on the root
// bubbles past every parent @binding without being seen, because the
// runtime keeps parent handlers in a registry only the output proxy reads.
client function dispatchCardNavigation(sourceEvent, item, index) {
output.navigate({ component: "Card", item: item, index: index })
}
client function dispatchCardNavigationValue(sourceEvent, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("navigate", true, false, {
client function dispatchCardNavigationValue(sourceEvent) {
output.navigate({
component: "Card",
value: sourceEvent.currentTarget.value
})
root.dispatchEvent(customEvent)
}
client function dispatchCardHeaderAction(sourceEvent, action, index, root, customEvent) {
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("action", true, false, {
component: "Card",
action: action,
index: index
})
root.dispatchEvent(customEvent)
client function dispatchCardHeaderAction(sourceEvent, action, index) {
output.action({ component: "Card", action: action, index: index })
}
client function dismissCard(sourceEvent, root, customEvent) {
client function dismissCard() {
dismissed = true
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
if (!root) {
return
}
customEvent = document.createEvent("CustomEvent")
customEvent.initCustomEvent("dismiss", true, false, {
component: "Card",
title: title
})
root.dispatchEvent(customEvent)
output.dismiss({ component: "Card", title: title })
}
}
@@ -137,8 +97,8 @@ component Card {
alt='{item.imageAlt || item.alt || ""}'
loading='{item.loading || "lazy"}'
decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { item: item, index: itemIndex } }))'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { item: item, index: itemIndex } }))'
@load='output.load({ item: item, index: itemIndex })'
@error='output.error({ item: item, index: itemIndex })'
/>
</div>
{/if}
@@ -157,7 +117,7 @@ component Card {
<a
href='{item.actionHref || item.href || "#"}'
class="wire-next__card-action"
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: item, index: itemIndex } }))'
@click='output.action({ item: item, index: itemIndex })'
>
<span>{item.actionLabel || "Learn more"}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
@@ -180,8 +140,8 @@ component Card {
alt='{imageAlt}'
loading="lazy"
decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
@load='output.load({ src: imageSrc })'
@error='output.error({ src: imageSrc })'
/>
<div class="wire-next__card-overlay-shade" aria-hidden="true"></div>
</div>
@@ -194,8 +154,8 @@ component Card {
alt='{imageAlt}'
loading="lazy"
decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
@load='output.load({ src: imageSrc })'
@error='output.error({ src: imageSrc })'
/>
</div>
{/if}
@@ -323,7 +283,7 @@ component Card {
<a
href='{actionHref || "#"}'
class="wire-next__card-action"
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { href: actionHref, label: actionLabel } }))'
@click='output.action({ href: actionHref, label: actionLabel })'
>
<span>{actionLabel}</span>
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
@@ -340,8 +300,8 @@ component Card {
alt='{imageAlt}'
loading="lazy"
decoding="async"
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
@load='output.load({ src: imageSrc })'
@error='output.error({ src: imageSrc })'
/>
</div>
{/if}
+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([]);
});