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..094b0ff991b 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js
+++ b/packages/@ember/-internals/glimmer/tests/integration/modifiers/on-test.js
@@ -296,6 +296,84 @@ 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 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:wrapped-button',
+ setComponentTemplate(
+ precompileTemplate(
+ `
`
+ ),
+ class extends Component {
+ inner = () => calls.push('inner');
+ wrapper = () => calls.push('wrapper');
+ }
+ )
+ );
+
+ let stop = false;
+
+ this.render(``, {
+ outer: (event) => {
+ calls.push('outer');
+ if (stop) event.stopPropagation();
+ },
+ });
+
+ runTask(() => this.$('button').click());
+ assert.deepEqual(calls, ['outer', 'inner', 'wrapper'], 'the ancestor listener runs last');
+
+ calls = [];
+ stop = true;
+ runTask(() => this.$('button').click());
+ assert.deepEqual(
+ calls,
+ ['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 51b4f48d00a..e8c38554895 100644
--- a/packages/@glimmer/runtime/lib/modifiers/on.ts
+++ b/packages/@glimmer/runtime/lib/modifiers/on.ts
@@ -313,6 +313,49 @@ 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
+
+ {{! When clicked, onOuterClick runs first, then onInnerClick }}
+
+ Click me
+
+
+ ```
+
+ 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}}`.
+
+ 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
In the example above, we used `@action` to ensure that `likePost` is