From f73ba4ff5919897b90eece49adc16e66d9ba85f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 03:24:51 +0000 Subject: [PATCH 1/2] Document the order of {{on}} listeners, ...attributes and classic handlers Adds a "Listener Order" section to the `{{on}}` docs covering: - listeners passed in through `...attributes` run before the element's own `{{on}}` listeners, wherever `...attributes` appears - classic component event handler methods like `click()` run after `{{on}}` listeners, and `stopPropagation()` in one of those listeners prevents them Both behaviors are pinned with tests so the docs stay accurate. Closes #17877 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013JirFwqPVyTTFj5zPbonSh --- .../tests/integration/modifiers/on-test.js | 78 +++++++++++++++++++ packages/@glimmer/runtime/lib/modifiers/on.ts | 39 ++++++++++ 2 files changed, 117 insertions(+) diff --git a/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js b/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js index 7673295ac58..6812a5c4f4b 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js @@ -6,6 +6,7 @@ import { precompileTemplate } from '@ember/template-compilation'; import { DEBUG } from '@glimmer/env'; import Component from '@glimmer/component'; +import ClassicComponent from '@ember/component'; moduleFor( '{{on}} Modifier', @@ -296,6 +297,83 @@ moduleFor( this.assertStableRerender(); this.assertCounts({ adds: 2, removes: 0 }); } + + "@test listeners from the invocation run before the element's own listeners, wherever ...attributes is (GH#17877)"( + assert + ) { + let calls = []; + + this.owner.register( + 'component:before-splat', + setComponentTemplate( + precompileTemplate(``), + class extends Component { + inner = () => calls.push('inner'); + } + ) + ); + + this.owner.register( + 'component:after-splat', + setComponentTemplate( + precompileTemplate(``), + class extends Component { + inner = () => calls.push('inner'); + } + ) + ); + + this.render( + ``, + { outer: () => calls.push('outer') } + ); + + runTask(() => this.$('#before').click()); + assert.deepEqual(calls, ['outer', 'inner'], 'own modifier before ...attributes'); + + calls = []; + runTask(() => this.$('#after').click()); + assert.deepEqual(calls, ['outer', 'inner'], 'own modifier after ...attributes'); + } + + '@test {{on}} listeners run before classic component event handler methods (GH#17877)'(assert) { + let calls = []; + + this.owner.register( + 'component:classic-clicker', + setComponentTemplate( + precompileTemplate(`Click`), + class extends ClassicComponent { + inner = () => calls.push('inner'); + + click() { + calls.push('click()'); + } + } + ) + ); + + this.render(``, { + outer: () => calls.push('outer'), + }); + + runTask(() => this.$('span').click()); + assert.deepEqual(calls, ['inner', 'outer', 'click()'], 'click() runs last'); + + calls = []; + runTask(() => + this.context.set('outer', (event) => { + calls.push('outer'); + event.stopPropagation(); + }) + ); + runTask(() => this.$('span').click()); + assert.deepEqual( + calls, + ['inner', 'outer'], + 'stopping propagation in an {{on}} listener prevents click()' + ); + } } ); diff --git a/packages/@glimmer/runtime/lib/modifiers/on.ts b/packages/@glimmer/runtime/lib/modifiers/on.ts index 51b4f48d00a..0ca3da5320a 100644 --- a/packages/@glimmer/runtime/lib/modifiers/on.ts +++ b/packages/@glimmer/runtime/lib/modifiers/on.ts @@ -313,6 +313,45 @@ function addEventListener( In this case, the `saveLike` function will receive two arguments: the click event and the value of `@post`. + ### Listener Order + + Listeners added with `{{on}}` are regular DOM event listeners, so they run in + the order they were added to an element, and an event reaches listeners on the + target element before it bubbles up to listeners on its ancestors. + + When a component forwards modifiers to an element with `...attributes`, the + `{{on}}` listeners passed in by the caller run before the listeners that the + component puts on that element itself. This is true wherever `...attributes` + appears in the element's attribute list: + + ```gjs {data-filename="app/components/my-button.gjs"} + + ``` + + ```gjs + + ``` + + If a component needs its own handler to run first, for example to decide + whether the caller's handler should run, have the caller pass the handler as + an argument and call it from the component's handler instead of passing it + with `{{on}}`. + + Event handler methods on classic components, such as `click()`, are run by + Ember's event dispatcher, which listens on the application's root element. + They run after any `{{on}}` listeners on the component's element and its + descendants, and calling `event.stopPropagation()` in one of those listeners + prevents them from running. + ### Function Context In the example above, we used `@action` to ensure that `likePost` is From f4cbcdcbb976827a642503e49d56202758329eca Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:21:15 +0000 Subject: [PATCH 2/2] Use a Glimmer component for the {{on}} ordering test Replace the classic component `click()` test with a @glimmer/component that has a listener on a wrapping element, and drop the classic handler section from the docs. Also document that changing an {{on}}'s arguments re-adds the listener, which moves it after the others. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013JirFwqPVyTTFj5zPbonSh --- .../tests/integration/modifiers/on-test.js | 44 +++++++++---------- packages/@glimmer/runtime/lib/modifiers/on.ts | 14 +++--- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js b/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js index 6812a5c4f4b..094b0ff991b 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js @@ -6,7 +6,6 @@ import { precompileTemplate } from '@ember/template-compilation'; import { DEBUG } from '@glimmer/env'; import Component from '@glimmer/component'; -import ClassicComponent from '@ember/component'; moduleFor( '{{on}} Modifier', @@ -336,42 +335,43 @@ moduleFor( assert.deepEqual(calls, ['outer', 'inner'], 'own modifier after ...attributes'); } - '@test {{on}} listeners run before classic component event handler methods (GH#17877)'(assert) { + "@test listeners on ancestors in a component run after the caller's listeners, unless propagation is stopped (GH#17877)"( + assert + ) { let calls = []; this.owner.register( - 'component:classic-clicker', + 'component:wrapped-button', setComponentTemplate( - precompileTemplate(`Click`), - class extends ClassicComponent { + precompileTemplate( + `
` + ), + class extends Component { inner = () => calls.push('inner'); - - click() { - calls.push('click()'); - } + wrapper = () => calls.push('wrapper'); } ) ); - this.render(``, { - outer: () => calls.push('outer'), + let stop = false; + + this.render(``, { + outer: (event) => { + calls.push('outer'); + if (stop) event.stopPropagation(); + }, }); - runTask(() => this.$('span').click()); - assert.deepEqual(calls, ['inner', 'outer', 'click()'], 'click() runs last'); + runTask(() => this.$('button').click()); + assert.deepEqual(calls, ['outer', 'inner', 'wrapper'], 'the ancestor listener runs last'); calls = []; - runTask(() => - this.context.set('outer', (event) => { - calls.push('outer'); - event.stopPropagation(); - }) - ); - runTask(() => this.$('span').click()); + stop = true; + runTask(() => this.$('button').click()); assert.deepEqual( calls, - ['inner', 'outer'], - 'stopping propagation in an {{on}} listener prevents click()' + ['outer', 'inner'], + "stopping propagation in the caller's listener prevents the ancestor listener" ); } } diff --git a/packages/@glimmer/runtime/lib/modifiers/on.ts b/packages/@glimmer/runtime/lib/modifiers/on.ts index 0ca3da5320a..e8c38554895 100644 --- a/packages/@glimmer/runtime/lib/modifiers/on.ts +++ b/packages/@glimmer/runtime/lib/modifiers/on.ts @@ -341,16 +341,20 @@ function addEventListener( ``` + This order is set when the listeners are first added. When the arguments to an + `{{on}}` change, for example because a different callback is passed in, that + listener is removed and added again, so it moves after the other listeners on + the element. + If a component needs its own handler to run first, for example to decide whether the caller's handler should run, have the caller pass the handler as an argument and call it from the component's handler instead of passing it with `{{on}}`. - Event handler methods on classic components, such as `click()`, are run by - Ember's event dispatcher, which listens on the application's root element. - They run after any `{{on}}` listeners on the component's element and its - descendants, and calling `event.stopPropagation()` in one of those listeners - prevents them from running. + Calling `event.stopPropagation()` in any of these listeners stops the event + from reaching listeners on ancestor elements, including ones the component + put on a wrapping element in its own template. It does not stop the other + listeners on the same element. ### Function Context