fix(csr): deliver focus and blur outputs from inside a component

A parent writing @focus on a component tag never heard that component's
own input or button take focus. The runtime bound every output-named DOM
fallback listener in the bubble phase, and focus and blur do not bubble,
so the event fired on the descendant and stopped there. Nothing errored --
the binding simply did nothing.

That made a whole class of declared outputs undeliverable: button.focus,
button.blur, TextLink.focus, TextLink.blur, WysiwygEditor.focus and
WysiwygEditor.blur all advertised events they could never send.

The ui ratchet for outputs nothing emits excluded natively-named outputs
on the grounds that a native event reaches the root anyway. That holds for
click and change, which bubble, and was wrong for focus and blur. Binding
those two in the capture phase makes the exclusion honest rather than
convenient; the ratchet's comment now says so.

Also documents WysiwygEditor as the chrome shell it is: it emits none of
its four outputs itself, it forwards whatever the slotted control raises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 16:31:33 +05:30
co-authored by Claude Opus 5
parent 84e58b1798
commit 3441b96362
5 changed files with 80 additions and 3 deletions
+1 -1
View File
@@ -89,7 +89,7 @@
"packages/ui/components/TogglePassword.wrn": "6bd4d9fa82b0c8e80178430d5a42b1b3ec9725fb62be158585f68af9338c901a", "packages/ui/components/TogglePassword.wrn": "6bd4d9fa82b0c8e80178430d5a42b1b3ec9725fb62be158585f68af9338c901a",
"packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b", "packages/ui/components/TreeView.wrn": "f54d8495730203b923629bcfb167d5ca41236af0101ea2723d33354413555b4b",
"packages/ui/components/Typography.wrn": "9676af57f2a937b21f45512e0a290b5bee4a16267fbf404850297d29645d88b5", "packages/ui/components/Typography.wrn": "9676af57f2a937b21f45512e0a290b5bee4a16267fbf404850297d29645d88b5",
"packages/ui/components/WysiwygEditor.wrn": "3f9d1765184581e3c1f335769a9f19969917450258bc95ba076980d99f5610b5", "packages/ui/components/WysiwygEditor.wrn": "a858ebd4e6e2672463ba57d4d049176ca1cc98dac32ab3df3638d6a9cdcd0f2d",
"packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d", "packages/ui/components/alert.wrn": "d42287e41b918b1e19962e9462ef203002eb5480acac984f115b711e9dbe663d",
"packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc", "packages/ui/components/avatar.wrn": "8bc705e459b13c7f063c57e1506ce540e50fa8ef9020ae85b6e8b6a3579fc5dc",
"packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1", "packages/ui/components/badge.wrn": "e44e33633fb34e897696cd9290f210108e35a3e2dc3b2a4a367411f45c69a1e1",
+10 -2
View File
@@ -2724,9 +2724,17 @@ export const REACTIVE_RUNTIME = String.raw`
); );
} }
}; };
node.addEventListener(outName, domListener); /*
* focus and blur do not bubble, so a bubble-phase listener on the
* component root never hears its own input or button take focus --
* the binding silently did nothing. Capture reaches the descendant.
* The same flag must be passed to removeEventListener or the
* listener outlives the component.
*/
var capture = outName === "focus" || outName === "blur";
node.addEventListener(outName, domListener, capture);
cleanupCallbacks.push(function () { cleanupCallbacks.push(function () {
node.removeEventListener(outName, domListener); node.removeEventListener(outName, domListener, capture);
}); });
}); });
}); });
+54
View File
@@ -1781,3 +1781,57 @@ test("a class binding inside data-for follows state the row never mentions", ()
expect(items()[0]?.classList.contains("is-active")).toBe(false); expect(items()[0]?.classList.contains("is-active")).toBe(false);
expect(items()[1]?.classList.contains("is-active")).toBe(true); expect(items()[1]?.classList.contains("is-active")).toBe(true);
}); });
test("a @focus binding on a component tag fires for a focusable element inside it", () => {
/*
* focus and blur do NOT bubble. The runtime bound every output-named DOM
* fallback listener in the bubble phase, so a parent writing @focus on a
* component tag never heard the component's own input or button take focus:
* the event fired on the descendant and stopped there. Nothing errored --
* the binding simply did nothing, which is why the library still declares
* focus/blur outputs on components that could never deliver them
* (button, TextLink, WysiwygEditor).
*
* The ui ratchet for "outputs nothing emits" excludes natively-named
* outputs on the stated grounds that a native event reaches the root
* anyway. That reasoning holds for click and change, which bubble, and
* fails for focus and blur, which do not.
*/
const win = mount(
`<div data-scope="seen: ''">` +
`<span id="out">{seen}</span>` +
`<div data-scope="n: 0" data-wrn-hydration="Field:x">` +
`<div data-wrn-events="focus" data-wrn-out-focus="seen = 'focused'">` +
`<button id="inner">press</button>` +
`</div></div></div>`,
);
const inner = win.document.querySelector("#inner") as unknown as {
dispatchEvent: (event: unknown) => boolean;
};
// A real focus event: fires on the descendant, does not bubble.
const FocusEventCtor = (win as unknown as { Event: typeof Event }).Event;
inner.dispatchEvent(new FocusEventCtor("focus", { bubbles: false }));
expect(win.document.querySelector("#out")?.textContent).toBe("focused");
});
test("a @change binding still fires for a bubbling event inside a component", () => {
// The companion case: change DOES bubble, and must keep working unchanged.
const win = mount(
`<div data-scope="seen: ''">` +
`<span id="out">{seen}</span>` +
`<div data-scope="n: 0" data-wrn-hydration="Field:y">` +
`<div data-wrn-events="change" data-wrn-out-change="seen = 'changed'">` +
`<input id="inner" />` +
`</div></div></div>`,
);
const inner = win.document.querySelector("#inner") as unknown as {
dispatchEvent: (event: unknown) => boolean;
};
const EventCtor = (win as unknown as { Event: typeof Event }).Event;
inner.dispatchEvent(new EventCtor("change", { bubbles: true }));
expect(win.document.querySelector("#out")?.textContent).toBe("changed");
});
+7
View File
@@ -1,4 +1,11 @@
component WysiwygEditor { component WysiwygEditor {
// A chrome shell around a slotted editor, not an editor itself: it renders a
// heading, a description, an optional item strip and a slot. It therefore
// emits none of these outputs on its own -- they forward whatever the slotted
// control raises. Slot a textarea or input and a parent @change or @input
// hears it, because those events bubble to this root; @focus and @blur arrive
// too, since the runtime binds the non-bubbling pair in the capture phase.
// Slot nothing interactive and there is nothing to hear.
outputs { outputs {
input(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) input(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null) change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
+8
View File
@@ -3320,6 +3320,14 @@ test("no component gains an output that nothing ever emits", () => {
* assumed: invokeComponentOutput falls back to dispatchComponentEvent when no * assumed: invokeComponentOutput falls back to dispatchComponentEvent when no
* handler is registered, and a parent @click on a component tag is also bound * 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. * as an ordinary DOM listener, so a natively-named output does arrive.
*
* That reasoning held only for events that bubble. focus and blur do not, so
* the bubble-phase fallback never heard a descendant take focus and every
* focus/blur output here was undeliverable -- silently, since a missing
* output raises nothing. The runtime now binds those two in the capture
* phase, which is what makes their exclusion honest rather than convenient.
* See "a @focus binding on a component tag fires for a focusable element
* inside it" in packages/csr/test/reactive.test.ts.
*/ */
const native = new Set([ const native = new Set([
"click", "click",