Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<button {{on 'click' this.inner}} ...attributes>Click</button>`),
class extends Component {
inner = () => calls.push('inner');
}
)
);

this.owner.register(
'component:after-splat',
setComponentTemplate(
precompileTemplate(`<button ...attributes {{on 'click' this.inner}}>Click</button>`),
class extends Component {
inner = () => calls.push('inner');
}
)
);

this.render(
`<BeforeSplat id="before" {{on 'click' this.outer}} /><AfterSplat id="after" {{on 'click' this.outer}} />`,
{ 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(
`<div {{on 'click' this.wrapper}}><button {{on 'click' this.inner}} ...attributes>Click</button></div>`
),
class extends Component {
inner = () => calls.push('inner');
wrapper = () => calls.push('wrapper');
}
)
);

let stop = false;

this.render(`<WrappedButton {{on 'click' this.outer}} />`, {
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"
);
}
}
);

Expand Down
43 changes: 43 additions & 0 deletions packages/@glimmer/runtime/lib/modifiers/on.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
<template>
<button {{on 'click' @onInnerClick}} ...attributes>
{{yield}}
</button>
</template>
```

```gjs
<template>
{{! When clicked, onOuterClick runs first, then onInnerClick }}
<MyButton {{on 'click' onOuterClick}} @onInnerClick={{onInnerClick}}>
Click me
</MyButton>
</template>
```

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
Expand Down
Loading