Repository: zauberzeug/nicegui
Pull request #4689 (2025-05-03 04:24:06)

feat: generalize js_handler
Closes: #4611

Follow up of PR #4618.

## Context

An insight emerge from the discussions in #4618, that the `js_handler` can be used to override the default event handler, and make much more customizations possible.

## Design

This PR allows setting `js_handler` and `handler` at the same time, when that happens, in `js_handler` you can call `defaultHandler` to recover the default behaviour which emits the event to server side handler.

```javascript
let defaultHandler = function(args: object)
```

And now you have much more freedom to customize the actual event args ~~, or to override the options~~ .

## Use Cases

* delegated event handling, unify children events handling on parent element:

  ```python
  data = [f"Item {i}" for i in range(10)]
  snippet = "".join(f"<li data-index={i}>{item}</li>" for i, item in enumerate(data))
  with ui.html(snippet, tag="ul") as c:
      c.on(
          "click",
          lambda e: ui.notify(f'Clicked on {e.args["index"]}'),
          js_handler="(e) => defaultHandler(e.target.dataset)",
      )
  ```

* [other use cases](https://github.com/zauberzeug/nicegui/pull/4618#issuecomment-2845918657)

## Potential Next Step

Since we can even pass the `args`/`throttle`/`leading_events`/`trailing_events` through `js_handle` too, maybe we can even deprecate those python fields to make the api more compact.

evnchn: @yihuang 

This PR looks _almost_ like #4618, but diverges crucially with refactoring `event.args`, `event.throttle`, `event.leading_events`, `event.trailing_events` into `event.options`. Can you explain why? 

My understanding is, it enables you to override the default applied from `mounted_app.elements[4].events.options`. 

<img width="510" alt="{6DC67F2A-8A52-4AF1-B9CD-AA6C29CC8A23}" src="https://github.com/user-attachments/assets/2b3f6eda-1d99-403d-8718-1666f69df187" />

Because, otherwise it would always honor `event.args`, `event.throttle`, `event.leading_events`, `event.trailing_events`, making it harder to customize. 

Please tell me if I am right, or react 👍

---

But, I have 2 issues: 

* The JavaScript API uses "camelCase" for functions (consider `document.getElementById`). I'd prefer `defaultHandler` over `default_handler` any day of the week. Calling `default_handler(...)` in JavaScript just feels wrong to me. 
* `default_handler` isn't in-spirit the "default" handler, if it enables customizing the options. It seems bulky to call it with `default_handler(null, e)` to do the bare minimum of a passthrough. I'd prefer having `defaultHandler(...args)` (new contender) which immediately calls `customizeOptionsHandler(null, ...args)` (<- this is what `default_handler` used to be)
evnchn: <img width="959" alt="{D2E8B39A-D81C-4B28-B1C1-840DB63363E2}" src="https://github.com/user-attachments/assets/21ac714b-e56f-46ef-8919-442f5d38cd9a" />

- `customHandler`: the most generic handler which allows customization of options, and is who makes the emit event. (used to be `default_handler` in your PR) 
- `defaultHandler`: calls `customHandler` with `options = null` to honor the default options`. (proposed by me)
- `finalHandler`: the handler that actually gets applied to the element. (want to change this so that it all follows `xxxHandler` format. 
evnchn: Or it should be `adjustableHandler`, since we are still customizing behaviour even if we use `defaultHandler` or `adjustableHandler`, just that `adjustableHandler` is adjustable. 

@falkoschindler need help with naming...
yihuang: > This PR looks almost like https://github.com/zauberzeug/nicegui/pull/4618, but diverges crucially with refactoring event.args, event.throttle, event.leading_events, event.trailing_events into event.options. Can you explain why?

Yeah, I smuggled that idea just to demonstrate those fields can be passed through `js_handle` directly, and render the python fields not necessary.
Let me know if we should discuss them separately, to keep this PR focus on `js_handler` itself without refactoring the options.
yihuang: > defaultHandler: calls customHandler with options = null to honor the default options`. (proposed by me)

thanks for the suggestions, I removed the options refactoring to keep the PR focus, and it's renamed to `defaultHandler` and no `options` parameter now.
evnchn: I think you can perhaps leave the options refactoring to another feature PR. This is because, **if it is deemed necessary,** then we can re-implement the `adjustableHandler`, while having `defaultHandler` share the same old signature, w/o breaking existing code. 

I also don't see why it is needed. Makes no reason to modify or use `args` in the first place as you are selecting stuff using JS already, and also I am not sure if `throttle()` function is happy, if you suddenly change the 3 parameters `throttle`, `leading_events` and `trailing_events` **from call to call**

And we are back to where we are at the end of #4618, good. 
falkoschindler: Regarding the "options refactoring": Of course we _can_ do a lot in JavaScript. But the point of NiceGUI is to provide a handy Python API where possible. So I'd rather leave throttle arguments in Python instead of moving more and more functionality into a string argument. If in doubt, let's discuss this refactoring in a separate feature request.

When it comes to the new `defaultHandler` function and the remaining API change, I'm still thinking about my comparison from https://github.com/zauberzeug/nicegui/pull/4618#issuecomment-2845829135:

> [...] You're allowing to register a Python and a JavaScript handler at the same time!
> 
> ```py
> root.on('click', handle_click, js_handler='(e) => emitEventToSelf(e.target.dataset)')
> ```
> 
> In contrast, this is the same line with `args_filter` [...] Actually, I lean towards calling it `args` like the already existing parameter:
> ```py
> root.on('click', handle_click, args='(e) => e.target.dataset')
> ```

I don't see why you would want to use two handlers that intransparently interact with each other. Extending the `args` argument to accept arbitrary JavaScript functions for filtering arguments seems so much cleaner to me. This way you don't have to think about the whole communication pipeline, but only the JavaScript event you want to consume and the data you want to extract from it.

Which use cases from https://github.com/zauberzeug/nicegui/pull/4689#issue-3037155663 or https://github.com/zauberzeug/nicegui/pull/4618#issuecomment-2845918657 are _not_ covered by my suggestion? In which scenario do we need "stacked" event handlers?
yihuang: > Extending the args argument to accept arbitrary JavaScript functions for filtering arguments seems so much cleaner to me.

I see what you mean, re-using the `args` also avoids introducing a new field here, makes sense to me.

WDYT, @evnchn? maybe better to not introduce a new field.
evnchn: @falkoschindler I still don't prefer shoving more functionality into `args`. 

But if you do insist, we just have to be very sure, that if we do let `args` to serve double-duty in its original behaviour, and also as a means to accept arbitrary JavaScript for filtering arguments, that we **absolutely have to make the second part clear in the documentation that it's a `eval()` under the hood on the client.**

My concern with argument which does not begin with `js` is that, people don't know that it is JS, pass a user-controllable f-string into it, and the code becomes vulnerable to XSS. 

I know you're like, we're not going to do that. But you have to consider the lower bound of people's understanding towards the library. 

Running JS isn't unsafe. Running JS when it is not expected, most certainly is. 

Case in point: In Python 2, the `input` function used to sneak in a `eval`, which is basically asking for trouble. 

```py
a = input('Give a: ')  # Python 2!
# Equivalent to
a = eval(raw_input('Give a: '))
```

Is the topic of many basic CTF challenges, because it hides in plain sight very well to normal Python users. 

They should have named the first one `eval_input` or something. 

---

Also, I don't quite get what you mean by "I don't see why you would want to use two handlers that intransparently interact with each other". Could you elaborate? 
yihuang: @evnchn Regarding the security concern, we can restrict it or json paths, it still covers all the known use cases, like `”$.event.button”` `”$.touches[0]”`
falkoschindler: > I still don't prefer shoving more functionality into args.

I see your point. But the alternative is to shove new functionality into `js_handler` or a completely new parameter, which increases the combinatorial complexity we have to take care of. I mean what if someone calls `defaultHandler` without a Python handler being registered. Or uses our hypothetical new `args_filter` parameter _and_ defines `args`. Giving all information to `args` and letting it handle this single responsibility feels right to me.

> have to make the second part clear in the documentation that it's a eval() under the hood

Ah, you're concerned about `eval`. That's a certainly good instinct, but this holds for any JavaScript NiceGUI accepts:
- `js_handler` for events,
- `element.run_method`,
- `ui.run_javascript`,
- values of dynamic props with `:` suffix, and
- `drag_constraints` for drag control in `ui.scene`.

We can improve the documentation accordingly. But I don't think there's a way around "arbitrary JavaScript" to solve this use case. The question is just in what form.

Regarding JSON paths: I think this works for some cases, but in some cases you might want to include if-statements or consolidate information via `forEach` or a regular for-loop. And we have to communicate a new syntax, like "use `$` as a placeholder for the event object".
falkoschindler: > Also, I don't quite get what you mean by "I don't see why you would want to use two handlers that intransparently interact with each other". Could you elaborate?

I mean that when registering event handlers, you usually can decide between a Python handler or a `js_handler`. Now this PR allows both at the same time. But if you write
```py
c.on("click", lambda e: ui.notify(...), js_handler="(e) => defaultHandler(...)")
```
it looks like a click event triggers `ui.notify` _and_ some JavaScript code calling `defaultHandler` - whatever this default handler is. But actually only the `js_handler` is evaluated on the client and `defaultHandler` emits to the server, where the Python callback is still listening and triggering the notification.

If you replace `defaultHandler` with `console.log` (for whatever reason), you don't get a notification:
```py
c.on("click", lambda e: ui.notify(...), js_handler="(e) => console.log(...)")  # no notification
```

And if you remove the `js_handler`, the notification is back.
```py
c.on("click", lambda e: ui.notify(...))  # notification is back
```

I _assume_ this is confusing for beginners.

evnchn: I _think_ it's not confusing, if you consider `js_handler` having the default value of `"(e) => defaultHandler(e)"` 

Then, by you setting the `js_handler` to `"(e) => console.log(...)"`, you "axed the functionality" of the js_handler, which, you did it to yourself. 

Also the point about `args_filter`: 
- With the `js_handler` filtering approach, I can choose to ignore the emit to server, but putting `defaultHandler` behind an if-else. - Not sure if that would be possible under the `args='(e) => e.target.dataset'` architecture. 
  - Even if I return nothing, I may be running `defaultHandler(undefined)`. 
yihuang: Agree with @evnchn here, adding functionality to `js_handler` is not more confusing than adding functionality to `args`, we just need to document it well.
yihuang: Out of topic to this PR.

Regarding the xss risk of eval js functions, have we explored the idea of compiling a python function ast to JavaScript, so we can write simple JavaScript function in python.
falkoschindler: Sorry, I'm still not convinced that a mix of `handler` and `js_handler` to filter arguments is a good solution. But since we seem to be stuck in disagreement, I asked ChatGPT (as neutrally as possible) to give a summary, a comparison and a recommendation:
https://chatgpt.com/share/68264b3b-3368-800c-b67f-a197f8b7699a

The verdict nails it pretty well:
> If NiceGUI aims for **maximum power for advanced users**, the current PR’s design (A) is the better fit: it exposes the full JS layer in a controlled, explicit way and keeps argument lists (`args`) purely declarative.
>
> If the priority is **reducing cognitive load for typical app authors**, extending `args` (B) would result in a cleaner, more Python-feeling API, provided the docs make the client-side-JS nature obvious and the security implications very clear.

**I think "reducing cognitive load for typical app authors" weighs more than "maximum power for advanced users".**

You can say "I think it's not confusing, if you consider `js_handler` having the default value of `"(e) => defaultHandler(e)"`". But try explaining this to your junior colleague who wants to receive certain arguments of a touch event. Would you rather explain the interplay of `handler`, `js_handler` and `defaultHandler` which are evaluated in a certain order spread across client and server - or would you rather explain a pure (no side effects) JavaScript function with one input `e` and an output of your choice?

Back to ChatGPT's summary. The two main disadvantages of my proposal (B) seem to be:

- **Less power for advanced users.**
    But I'd argue that you already have maximum power with the existing implementation. Throttling is even natively supported. [Many use cases](https://github.com/zauberzeug/nicegui/pull/4618#issuecomment-2845918657) would benefit from a _simple_ solution for filtering event arguments.
- **Danger of `eval`**
    If this is a concern, we need to address it throughout the code base and/or educate our users. And I don't doubt the theoretical possibility of someone registering a new event handler and using user input as part of the argument filter. But in reality it seems extremely unlikely and pointless.

yihuang: @falkoschindler I'm ok with both options. But when I try to implement the args solution, it's a little bit tricky to handle the typings and conversions. previously, we'll convert `Sequence[str]` to `Sequence[Sequence[str]]`, but now when we pass a js function, we need to convert `str` to `Sequence[str]`, put it more concretely, we have many combinations here:

1. `"(e) => e.target.dataset"` => `["(e) => e.target.dataset"]`
1. `["(e) => e.target.dataset"]` => no change
1. `["a"]` => `[["a"]]`
1. `[["a"]]` => no change

But we can't distinguish option 2 and 3, because they both have type `Sequence[str]`.
evnchn: @yihuang I think the way of his intention is: 

`args` used to have type `Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]]`, right? 

Now we Union `str` on top, resulting in `Union[None, str, Sequence[str], Sequence[Optional[Sequence[str]]]]`

**If it is a `str`, we don't make it into a `Sequence[str]`. Rather, the new code branch takes over.** 

All other case, inherit old behaviour. 

1. "(e) => e.target.dataset" => no change (new code branch)
2. ["(e) => e.target.dataset"] => **Illegal**, they just do the old-style filtering via `stringifyEventArgs(args, event.args)` and get nothing
3. ["a"] => [["a"]] (as usual)
4. [["a"]] => no change (as usual)

---

But yeah, this illustrates the confusion perfectly @falkoschindler. 

Point 2 is basically the common pitfall that people will fall into, when they use the new `args` with the 4-way Union type. They may easily _expect_ if they pass a `str`, that it will become `Sequence[str]` and then `Sequence[Optional[Sequence[str]]]]` and use the old-style filtering. 

But no, suddenly the new branch takes over, since we have elected to make `args` serve double-duty depending on the **type** of parameter passed. I think this goes against "reducing cognitive load for typical app authors" by a long shot. Do you want to check this point with ChatGPT to see what it thinks? 

If you do insist on using `args`, which I discourage, I have the following proposal: 

<img width="532" alt="{98DCF80F-1B9B-4DA9-A2EF-1862D8A17775}" src="https://github.com/user-attachments/assets/99a13635-d00c-442b-81c5-508741e97735" />

Can we define `JavaScriptSnippet`, so that the type becomes `Union[None, JavaScriptSnippet, Sequence[str], Sequence[Optional[Sequence[str]]]]`? 



yihuang: ```python
def _normalize_event_args(
    args: Union[None, str, Sequence[str], Sequence[Union[None, str, Sequence[str]]]],
) -> Union[None, Sequence[Union[None, str, Sequence[str]]]]:
    '''
    Normalize user input args to the format that's passed to frontend:
    * `None | Sequence[None]` => don't filter anything.
    * `Sequence[str]` => evaluate js function.
    * `Sequence[Sequence[str]]` => list of fields.
    '''
    if args is None:
        return args

    if isinstance(args, str):
        # single js function
        return [args]

    if isinstance(args[0], str):
        if "=>" not in args[0]:
            # single list of fields
            return [args]

    return args
```

This normalization function might work though, use the substring `"=>"` to identify a js function.
yihuang: do we want to support something like this:

```python
[
  ["a", "b"],
  "(e) => e.event.dataset",
]
```
falkoschindler: Yes, I thought `args` would simply get another option of type `str`. But I see the confusion about extending the existing (not so nice) signature to cover yet another feature.

So I continued my discussion with ChatGPT:
https://chatgpt.com/share/68264b3b-3368-800c-b67f-a197f8b7699a

As it turns out, `str` is considered a subclass of `Sequence[str]`. This means the 4-way union confuses users and static type checkers alike. (Here is another interestingly confusing discussion about this matter: https://claude.ai/share/ba1e89e8-0772-4905-8c27-5cd1fa1be8ce. 🤦🏻‍♂️) Therefore we discussed the introduction of an additional parameter `args_transform` to be used _instead of_ `args` if needed.

This sounds like a lot of possible combinations, but it's actually only three:
```py
@overload
def on(self, type: str,
       handler: Optional[events.Handler[events.GenericEventArguments]] = None,
       args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None, *,
       throttle: float = 0.0, leading_events: bool = True, trailing_events: bool = True) -> Self: ...

@overload
def on(self, type: str,
       handler: Optional[events.Handler[events.GenericEventArguments]] = None,
       args_transform: str, *,
       throttle: float = 0.0, leading_events: bool = True, trailing_events: bool = True) -> Self: ...

@overload
def on(self, type: str, *,
       js_handler: Optional[str] = None) -> Self: ...
```

- You can use `handler`:
    - With `args` you define individual arguments as a sequence or sequence of sequences (as before).
    - With `args_transform`, you specify arguments using a JavaScript function (new).
- Or you use `js_handler`:
    - Then `args` and `args_transform` don't apply (as before).

First I was unsure about the name `args_transform`. But since it can do more than a plain filter function (which would return true or false per item), the name "transform" seems right. (I don't like the name `args_js` because it doesn't name what it is and the similarity to `js_handler` adds confusion because they are not meant to be used in combination.)
evnchn: As a recap, it used to be 

- You can use `handler`:
  - Optionally, with `args` you define individual arguments as a sequence or sequence of sequences
- Or you use `js_handler`:
  - Then `args` and `handler` don't apply

I'll **bold the changes**. 

---

> str is considered a subclass of Sequence[str]

This is completely unexpected 🤯 Sorry I'm not really a type expert...

But I think at the end of the day, I'm guessing @falkoschindler's initial point is, we should try and avoid shoving more functionality into `js_handler` to avoid confusion, so that's why he was first proposing putting it into `args`, and once that didn't turn out (_breathes a sign of relief_), he is now proposing `args_transform` as the second choice. 

---

But, I think `args_transform` misses the fact that it is a JavaScript-based transform operation. Also I think it should be a noun (transformer?) not a verb (transform)

So at the very least, I propose `js_transformer` in place of `args_transform`

- You can use `handler`:
  - Optionally, with `args` you define individual arguments as a sequence or sequence of sequences (as before).
  - **OR, Optionally, with `js_transformer`, you specify arguments using a JavaScript transformer function (new).**
- Or you use `js_handler`:
  - Then `args` **and `args_transform`** don't apply (as before).

Note the role of JS in this paradigm. You use JS to either: 

- handle (`js_handler`): then you don't get to emit events to the server, but get to trigger side-effects on the page
- transform (`js_transformer`): then you do get to emit events to the server, but side-effects are discouraged just like binding transformation functions. 

---

Then I have a question to ask: 

Suppose I want, on a button click, to:

1. Not only run some JavaScript (say `js_handler = 'document.getElementById('msgbox").innerText = e.offsetY'`) 
2. AND also to emit that thing to server (`js_transformer = "(e) => e.offsetY"`).

Then I have to attach twice via `.on`, and the order of execution is determined by the order of attachment to the element... Not sure if that's weird, and I think perhaps having integrated functionality of both "handle" and "transform" in `js_handler` may be more intuitive at this point. Since transformation is also part of handling? 

---

Just so we are on the same page, here is what it is like, if you use the "generalized `js_handler` scheme"

- You can use `handler`:
  - Optionally, with `args`, you define individual arguments as a sequence or sequence of sequences (as before)
- **AND/OR**, with `js_handler` you can:
  - Pass in the old-type JS functions which doesn't call `defaultHandler` in any way, then `args` and `handler` don't apply (as before)
  - **Pass in new-type JS functions which invole `defaultHandler` with filtering on input parameter `e`, and the `js_handler` can then acts also as a transformer.** 

But this seems tricky to document, and so by extension tricky to learn as outsiders, because we change OR to AND/OR. 

---

Overall **I begin to like `js_transformer` because changing a OR to an AND/OR` would involve "un-learning" that `handler` and `js_handler` is mutually-exclusive**, which affects the general crowd and more than the few who'd like both a `js_handler` and a `js_transformer`

But, I'd like to ask if we can have both solutions, such that the easy route is `js_transformer`, and the advanced route is the generalized `js_handler` with `defaultHandler` invocation? Or would that muddy the water further... Perhaps we can leave it to later when there is truly a need (like when attaching both `js_handler` and `js_transformer`, some code breaks)

yihuang: "generalize js_handler" is a superset of the `args_transform` schema, there are things can be done with `js_handler` which can't be done with `args_transform` as @evnchn demonstrated, I still vote for "generalize js_handler" solution.
evnchn: > there are things can be done with js_handler which can't be done with args_transform

@yihuang I think I disagree. I just pointed out we can do `"(e) => {yourCodeHere(); return e.event.button;}"`. 

I'd imagine if we return nothing (aka undefined) then we would skip the sending of data to server. 

Thus, it is possible `arg_transform` (I propose to rename to `js_transformer`) can do everything the `js_handler` can do, including transform + running JS side-effect, as well as optional sending to the server. 
yihuang: > > there are things can be done with js_handler which can't be done with args_transform
> 
> 
> 
> @yihuang I think I disagree. I just pointed out we can do `"(e) => {yourCodeHere(); return e.event.button;}"`. 
> 
> 
> 
> I'd imagine if we return nothing (aka undefined) then we would skip the sending of data to server. 
> 
> 
> 
> Thus, it is possible `arg_transform` (I propose to rename to `js_transformer`) can do everything the `js_handler` can do, including transform + running JS side-effect, as well as optional sending to the server. 

I see, it can, but in a hacky way.

The `js_handler` approach can be seen as unify the previous two exclusive cases (server side handler vs client side handler) into a single case (overridable client side handler + optional server side handler). I think It's cleaner with the unified way, and more customizable. Just need a little bit of mindset transformation from the current behavior.
falkoschindler: > Seems like neither solution planned itself against multiple arguments?

Hm, the transformer should be able to handle a function like "(evt, row, index) => row".

> I propose to rename to `js_transformer`

I'm getting used to it. So you either have a Python `handler` with an optional `js_transformer`, or a pure `js_handler`.

> I'd imagine if we return nothing (aka undefined) then we would skip the sending of data to server.

I thought `undefined` would mean emitting an event without arguments. If you don't want to emit anything, you can handle the event solely on the client using `js_handler`.

> including transform + running JS side-effect

What would be a real-world scenario for such a handler? Is there demand for it?

---

To be honest, I'm thinking about another **approach C** very close to the original PR:

What if we indeed allow Python _and/or_ JS handlers. But instead of introducing a function `defaultHandler`, we evaluate the return value of `js_handler`. The resulting mindset would be as follows:
- The optional Python `handler` defines how to handle what is sent by the client.
- The optional `js_handler` defines how the event is handled on the client. Its return value (if any) is sent to the Python handler.

(I would like to flip the order, but the positional `handler` can't go after the keyword argument `js_handler`.)

I know that I'm contradicting my previous argumentation (away from changing these handlers and towards a new way to filter arguments). But it feels like using the `return` value of `js_handler` makes everything so intuitive. ChatGPT seems to agree:

> Approach C keeps the power of A (conditional emit, client-side preprocessing) but is as simple to teach as B (“whatever you return is what Python sees”).

Sorry for throwing another option into the ring after we already started converging towards an agreement. But better now than shortly after releasing a suboptimal API.

evnchn: @falkoschindler I must ask: Isn't approach C basically what @yihuang's original "generalize js_handler" does, but before he added the custom options thing (which we ruled out)?
evnchn: Ah, I get what you are getting at! Approach C (numbered 3 below) is NOT THE SAME to either existing approaches. 

Basically, in our old options: 

1. `js_handler` couldn't care less about the return value, and _if we generalize it_ we can invoke `defaultHandler yourself. 
2. `js_transformer` intercepts the return value, and invokes `defaultHandler` with it. 

(I assume the idea of 4-way Union in `args` is more than dead in the water)

Now, your new option is: 

3. `js_handler` would now also intercept the return value, and should it be returned, we invoke `defaultHandler` with it. 

This, in my opinion, has one obvious issue: 

- What happens to existing `js_handler`s in user's code pre-PR, which potentially return a value? 



falkoschindler: @evnchn Yes, I know, it's very close. But somehow in my head it makes a difference if (A) `js_handler` needs to call another JavaScript function to trigger the Python handler (smells "procedural"), or if (C) `js_handler` defines (returns) what is sent to the Python handler (like "functional" programming).

But maybe it's just my mind running in circles. I need to sleep over it (yet again).
falkoschindler: > What happens to existing js_handlers in user's code pre-PR, which potentially return a value?

Nothing much because no Python handler is listening. But we could indicate to the client if there is a Python handler or not. Then the client would only emit if there is a return value _and_ a Python handler.
evnchn: @yihuang Can you resolve this branch's merge conflicts?
falkoschindler: > Kept the entirety of old architecture, since we hide our new functionality behind the AND-case of handler and js_handler, which could never happen before.

Exactly. This is why I don't think it's a breaking change.

>  imagine setting a flag JS_TRANSFORMATION = True somewhere, marking that we now suddenly do care about the return value

That's basically what I was proposing:

> But we could indicate to the client if there is a Python handler or not. Then the client would only emit if there is a return value and a Python handler.

👍🏻
evnchn: We will re-start this discussion with the newly-proposed "Option C"

- Which I name #4759 and made a PR there. Let's discuss here. 
- Since both me and @falkoschindler agree, we're finally moving forward on this one.
falkoschindler: @yihuang When creating PR #4759, @evnchn overlooked the fact that this would replace your PR without your contribution being [officially](https://github.com/zauberzeug/nicegui/graphs/contributors) recognized.
But we would love to have your PR accepted after all the discussion it went through. So if you're still motivated, feel free to use code from PR 4759 to update your PR as needed. I can also take over whenever you wish to add documentation and pytests.
I feel sorry for the rough experience you had with contributing to NiceGUI for the first time: one PR closed and me pushing back on the second one for so long. Maybe it's been too many cooks; or maybe this piece of API is just really tricky to get right.
Anyway, let me know how you would like to proceed. Thanks!
evnchn: Sidenote: You by-all-means can take my code in #4759. It is highly-inspired by yours, and you rightfully should have your contribution recognized. You don't need permission otherwise, but I'd like like to point it out regardless ❤️.
yihuang: First of all, allow me to rename the `defaultHandler` to `emit` before I provide my longer argument ;D
evnchn: I think only the second argument is a firm one for your "generalize js_handler" approach. 

#### Conditional event triggering

We have established that if we take the return-value approach, returning undefined / return nothing = don't emit anything back to the server, so the following would work: 

```py
.on('blah', handler=python_handler, js_handler='(e) => {return e.event.button != 0 ? e.event.button : undefined')
```

Granted #4759 doesn't do this, but it's one conditional check away. 

#### Deferred event triggering

**This is where only your solution can work, and any return-value oriented approach would not.** Since in all 3 of your snippets, they would return undefined (I think? Even then you may be returning a Promise only, which is useless), we would have "missed the opportunity" to return value if we take the `js_transform` or `js_handler` with ARVI approach. 

-> First off, I argue it would make things more confusing, if we let user return a Promise in their js_handler, then nicegui.js check if it's a Promise and `.then` it to get the result. 

-> So can it be possible that we both have the `emit` defined in the JavaScript snippet namespace, and we also have the ARVI approach, so that people who need the late `emit` can `(e) => {fetchElsewhere(e.event.button).then((data) => emit(data));}` while the people who don't can simply `(e) => e.event.button` for a shorter syntax. 

-> But not sure if having 2 ways of doing it, the latter being more of a shorthand, is desirable at this point. 
evnchn: I also think the scale also shifted a bit in your favor, when you renamed the `defaultHandler` to `emit`. If it is just a 4-character function, it may not matter much from the shorthand to ` (e) => e.event.button` to the "proper" way of ` (e) => emit(e.event.button)`, then. 
yihuang: I'd still argue that the approach (C) feels a bit hacky to me, because `js_handler` as we know today is supposed to do side effects, when you do side effects, the timing of calls is relatively important.
but when combined with `handler`, `js_handler` is wrapped in the throttle control, which makes it a bit more hacky for arbitrary side effects I think.
evnchn: > In proposal (C), but when combined with handler, js_handler is wrapped in the throttle control, which makes it a bit more hacky for arbitrary side effects I think.

I don't think so. A wrapped and throttled `emit` back to server is useful because (1) read #4754, we need to wait for the handshake, (2) we don't want to overwhelm the server. 

If I am not mistaken, your JavaScript code **will always get ran**, but: 
- if it even managed to emit before handshake is done, the message will be held back with a 10ms retry interval until handshake
- if it emits excessive events, then it is throttled

So in essense, we wrap and throttle the transport only, which by-all-means we could and should. JavaScript-side, freedom is still had. 
yihuang: > > In proposal (C), but when combined with handler, js_handler is wrapped in the throttle control, which makes it a bit more hacky for arbitrary side effects I think.
> 
> 
> 
> I don't think so. A wrapped and throttled `emit` back to server is useful because (1) read #4754, we need to wait for the handshake, (2) we don't want to overwhelm the server. 
> 
> 
> 
> If I am not mistaken, your JavaScript code **will always get ran**, but: 
> 
> - if it even managed to emit before handshake is done, the message will be held back with a 10ms retry interval until handshake
> 
> - if it emits excessive events, then the **event emission** is throttled (but your code still gets ran)
> 
> 
> 
> So in essense, we wrap and throttle the transport only, which by-all-means we could and should. JavaScript-side, freedom is still had. 

I didn't argue against the throttle control itself, I was argue against executing `js_handler` inside throttle control, which maybe unexpected if user intents to do side effects in it.
evnchn: Not really sure if that's the case right now with either PRs, this and #4759. 

If it did, surely it's unintended, and we should move the user's code execution outside of the throttle. 

I'll read both PR's code when I'm back from Lego House today. 

yihuang: > Not really sure if that's the case right now with either PRs, this and #4759. 
> 
> 
> 
> If it did, surely it's unintended, and we should move the user's code execution outside of the throttle. 
> 
> 
> 
> I'll read both PR's code when I'm back from Lego House today. 
> 
> 

Oh, I was wrong about that, the js_handler is not executed inside throttle control in both PRs.
evnchn: @falkoschindler here is the summary so far: Sorry we are back to 3 options...

- `js_handler` with `emit` function generalization
  - Slightly more complex syntax
- `js_handler` with ARVI as in #4759
  - Cannot handle late return, such as Promise, setTimeout
- `js_handler` with both approaches
  - May be even more confusing

@yihuang I want to ask: what if I ran the `emit` function multiple times in one handler? Will it break things?
yihuang: > @yihuang I want to ask: what if I ran the emit function multiple times in one handler? Will it break things?

I think it'll just call server side handler multiple times.
evnchn: #### If you want to endorse multi-call

I think NiceGUI side, there _may_ be mechanisms which avoids the message acting twice when sent twice. 

Can you try it out (or maybe I try it when I have time), just so we are sure?

It is best if we can disable that mechanism if `js_handler` is passed. 

#### If you don't want to endorse multi-call

Then the second call to `emit` must either do nothing, warn and do nothing, or error out.

We need extra code around the `emit` to detect being called twice on a by-event basis.


yihuang: > Can you try it out (or maybe I try it when I have time), just so we are sure?

I just tried, it'll call multiple times, I'll put some of these edge cases into the unit test case.
falkoschindler: This thread is _crazy_! After discussing so many options and considering so many aspects, I'm now starting to favor the initial implementation. 🤪

Renaming `defaultHandler` into `emit` is a great idea though! It feels more intuitive to say "the default handling in JavaScript is to `emit` the event arguments to the server".
- If you want to emit less, filter the arguments as you like.
- If you don't want to emit to the server, don't do it and don't specify a `handler`.
- If you want to conditionally emit, no problem.

Some remarks about the current implementation:
- To communicate this mindset, I'd suggest to define `js_handler: Optional[str] = '(...args) => emit(...args)'`. The `Optional` is only for avoiding a breaking change, should be deprecated and removed in 3.0.
- Let's make `emit` constant.
- Regarding the removed overloads: Previously there were basically two separate cases, `handler` or `js_handler`, which was communicated using two overloads. Now everything is one single API. I wonder if we should keep the `on(self, type, *, js_handler)` overload to communicate: If you don't pass a `handler`, don't try to pass `args` or throttle arguments. But maybe this makes everything just more complicated than necessary.

Anyway, I think we're getting close to a final solution! 😃
evnchn: **I'm glad that we converged on "generalize `js_handler` with `emit`"** 🎉 I'm going to stop work on #4759. @yihuang add oil while I finish my exams in the upcoming weeks! Particularly since I'm weak in Python's `overload` (seems completely magical to me)

Some small details: 

> `js_handler: Optional[str] = '(...args) => emit(...args)'`, `Optional` being for avoid breaking change

Do bear in mind that for performance reasons, we would probably not re-transmit `'(...args) => emit(...args)'` over and over again. We'll match against that placeholder `js_handler`, and if it is passed, we'll efficiently switch to `handler = emit` in `nicegui.js`
yihuang: > Let's make emit constant.

done.

> I'd suggest to define js_handler: Optional[str] = '(...args) => emit(...args)'

I did it with `emit` alone, which is equivalent but with a little bit less overhead, but the suggested may communicate better, feel free to change it to the full version if insist, I'm fine with both.
evnchn: > I did it with emit alone, which is equivalent but with a little bit less overhead

The purpose of the default `js_handler` is for it to show up in VSCode when people code. It is merely an intention indicator, and having `'(...args) => emit(...args)'` is much more clear what they can do with it. 

<img width="427" alt="{DB181BF3-8DD6-44F3-B1DF-520E7BDB3A2B}" src="https://github.com/user-attachments/assets/94cfd420-f2dd-4805-a60e-fe0fe27dc4b7" />

In the end, the length of the intention indicator should not matter, because if we detect it is unchanged (aka did not pass in), then we would replace it with `None` intentionally, such that the `else` code path gets taken here. 

<img width="400" alt="{7E3B15F2-2CAA-4CEA-976A-73246EE710C9}" src="https://github.com/user-attachments/assets/30b44106-e2d4-4dc6-bebd-d229ae1be436" />

---

-> Therefore, IMO there is no need to "chase after the _shortest_ default `js_handler`", rather we would want to "chase after the _clearest_ default `js_handler` to illustrate the working principle. However long it is, it should not transmitted anyways, so it'd be fine. 

evnchn: I just talked about a 4-way Union in another thread and that reminds me of this PR 😅

For defining `DEFAULT_JS_HANDLER`, I think: 

This is good-enough, since docstring shows up. 

![image](https://github.com/user-attachments/assets/2fab079c-bc91-4928-b389-0dd522958b49)

While this may be slightly opaque, since the docstring is all the way at the bottom, and I can't see `(...args) => emit(...args)` unless I scroll down. 

![image](https://github.com/user-attachments/assets/29c4570b-8d2f-4d9c-82a4-7f1a2b30ba2e)

---

Inlining the constant makes the guide to user more clear in the IDE, but ruins the DRY principle... Not sure. Let's ask @falkoschindler 

<img width="744" alt="{FEF44F1B-A7BC-47D0-92E4-1D1F06D0715D}" src="https://github.com/user-attachments/assets/702dc138-d2e8-4b37-84c5-b0c1b4188752" />

<img width="586" alt="{0B62F884-C0CB-464E-8226-04908462D250}" src="https://github.com/user-attachments/assets/4da604f4-9943-4c09-8c66-7ab596276a05" />



Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..c83212f9
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,16 @@
+### Motivation
+
+<!-- What problem does this PR solve? Which new feature or improvement does it implement? -->
+<!-- Please provide relevant links to corresponding issues and feature requests. -->
+
+### Implementation
+
+<!-- What is the concept behind the implementation? How does it work? -->
+<!-- Include any important technical decisions or trade-offs made -->
+
+### Progress
+
+- [ ] I chose a meaningful title that completes the sentence: "If applied, this PR will..."
+- [ ] The implementation is complete.
+- [ ] Pytests have been added (or are not necessary).
+- [ ] Documentation has been added (or is not necessary).
diff --git a/fly.dockerfile b/fly.dockerfile
index 1a56ac93..23b4781b 100644
--- a/fly.dockerfile
+++ b/fly.dockerfile
@@ -46,5 +46,6 @@ COPY fly-entrypoint.sh /entrypoint.sh
 ENTRYPOINT ["/entrypoint.sh"]
 
 ENV PYTHONUNBUFFERED=1
+ENV ENABLE_ANALYTICS=true
 
 CMD ["python", "main.py"]
diff --git a/main.py b/main.py
index 64413b4e..cf96ce45 100755
--- a/main.py
+++ b/main.py
@@ -8,7 +8,7 @@ from fastapi.responses import RedirectResponse
 from starlette.middleware.sessions import SessionMiddleware
 
 from nicegui import app, ui
-from website import anti_scroll_hack, documentation, fly, main_page, svg
+from website import anti_scroll_hack, documentation, fly, imprint_privacy, main_page, svg
 
 # session middleware is required for demo in documentation
 app.add_middleware(SessionMiddleware, secret_key=os.environ.get('NICEGUI_SECRET_KEY', ''))
@@ -51,6 +51,11 @@ def _documentation_detail_page(name: str) -> Optional[RedirectResponse]:
     raise HTTPException(404, f'documentation for "{name}" could not be found')
 
 
+@ui.page('/imprint_privacy')
+def _imprint_privacy() -> None:
+    imprint_privacy.create()
+
+
 @app.get('/status')
 def _status():
     return 'Ok'
diff --git a/nicegui/element.py b/nicegui/element.py
index ae80255a..331aaeb8 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -4,7 +4,7 @@ import inspect
 import re
 from copy import copy
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast, overload
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, List, Optional, Sequence, Union, cast
 
 from typing_extensions import Self
 
@@ -325,15 +325,6 @@ class Element(Visibility):
             Tooltip(text)
         return self
 
-    @overload
-    def on(self,
-           type: str,  # pylint: disable=redefined-builtin
-           *,
-           js_handler: Optional[str] = None,
-           ) -> Self:
-        ...
-
-    @overload
     def on(self,
            type: str,  # pylint: disable=redefined-builtin
            handler: Optional[events.Handler[events.GenericEventArguments]] = None,
@@ -342,31 +333,40 @@ class Element(Visibility):
            throttle: float = 0.0,
            leading_events: bool = True,
            trailing_events: bool = True,
-           ) -> Self:
-        ...
-
-    def on(self,
-           type: str,  # pylint: disable=redefined-builtin
-           handler: Optional[events.Handler[events.GenericEventArguments]] = None,
-           args: Union[None, Sequence[str], Sequence[Optional[Sequence[str]]]] = None,
-           *,
-           throttle: float = 0.0,
-           leading_events: bool = True,
-           trailing_events: bool = True,
-           js_handler: Optional[str] = None,
+           js_handler: Optional[str] = '(...args) => emit(...args)',  # DEPRECATED: None will be removed in version 3.0
            ) -> Self:
         """Subscribe to an event.
 
+        The event handler can be a Python function, a JavaScript function or a combination of both:
+
+        - If you want to handle the event on the server with all (serializable) event arguments,
+          use a Python ``handler``.
+        - If you want to handle the event on the client side without emitting anything to the server,
+          use ``js_handler`` with a JavaScript function handling the event.
+        - If you want to handle the event on the server with a subset or transformed version of the event arguments,
+          use ``js_handler`` with a JavaScript function emitting the transformed arguments using ``emit()``, and
+          use a Python ``handler`` to handle these arguments on the server side.
+          The ``js_handler`` can also decide to selectively emit arguments to the server,
+          in which case the Python ``handler`` will not always be called.
+
+        Note that the arguments ``throttle``, ``leading_events``, and ``trailing_events`` are only relevant
+        when emitting events to the server.
+
+        *Updated in version 2.18.0: Both handlers can be specified at the same time.*
+
         :param type: name of the event (e.g. "click", "mousedown", or "update:model-value")
         :param handler: callback that is called upon occurrence of the event
-        :param args: arguments included in the event message sent to the event handler (default: `None` meaning all)
+        :param args: arguments included in the event message sent to the event handler (default: ``None`` meaning all)
         :param throttle: minimum time (in seconds) between event occurrences (default: 0.0)
-        :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: `True`)
-        :param trailing_events: whether to trigger the event handler after the last event occurrence (default: `True`)
-        :param js_handler: JavaScript code that is executed upon occurrence of the event, e.g. `(evt) => alert(evt)` (default: `None`)
+        :param leading_events: whether to trigger the event handler immediately upon the first event occurrence (default: ``True``)
+        :param trailing_events: whether to trigger the event handler after the last event occurrence (default: ``True`)
+        :param js_handler: JavaScript function that is handling the event on the client (default: "(...args) => emit(...args)")
         """
-        if handler and js_handler:
-            raise ValueError('Either handler or js_handler can be specified, but not both')
+        if js_handler is None:
+            helpers.warn_once('Passing `js_handler=None` to `on()` is deprecated. '
+                              'Use the default "(...args) => emit(...args)" instead or remove the parameter.')
+        if js_handler == '(...args) => emit(...args)':
+            js_handler = None
 
         if handler or js_handler:
             listener = EventListener(
diff --git a/nicegui/static/nicegui.js b/nicegui/static/nicegui.js
index 0de58480..6323ed64 100644
--- a/nicegui/static/nicegui.js
+++ b/nicegui/static/nicegui.js
@@ -177,27 +177,29 @@ function renderRecursively(elements, id) {
     let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1);
     event.specials.forEach((s) => (event_name += s[0].toLocaleUpperCase() + s.substring(1)));
 
+    const emit = (...args) => {
+      const emitter = () =>
+        window.socket?.emit("event", {
+          id: id,
+          client_id: window.clientId,
+          listener_id: event.listener_id,
+          args: stringifyEventArgs(args, event.args),
+        });
+      const delayed_emitter = () => {
+        if (window.did_handshake) emitter();
+        else setTimeout(delayed_emitter, 10);
+      };
+      throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
+      if (element.props["loopback"] === False && event.type == "update:modelValue") {
+        element.props["model-value"] = args;
+      }
+    };
+
     let handler;
     if (event.js_handler) {
       handler = eval(event.js_handler);
     } else {
-      handler = (...args) => {
-        const emitter = () =>
-          window.socket?.emit("event", {
-            id: id,
-            client_id: window.clientId,
-            listener_id: event.listener_id,
-            args: stringifyEventArgs(args, event.args),
-          });
-        const delayed_emitter = () => {
-          if (window.did_handshake) emitter();
-          else setTimeout(delayed_emitter, 10);
-        };
-        throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
-        if (element.props["loopback"] === False && event.type == "update:modelValue") {
-          element.props["model-value"] = args;
-        }
-      };
+      handler = emit;
     }
 
     handler = Vue.withModifiers(handler, event.modifiers);
diff --git a/website/documentation/content/element_documentation.py b/website/documentation/content/element_documentation.py
index 933456cb..45904589 100644
--- a/website/documentation/content/element_documentation.py
+++ b/website/documentation/content/element_documentation.py
@@ -9,6 +9,39 @@ def main_demo() -> None:
         ui.label('inside a colored div')
 
 
+@doc.demo('Register event handlers', '''
+    The event handler can be a Python function, a JavaScript function or a combination of both:
+
+    - If you want to handle the event on the server with all (serializable) event arguments,
+        use a Python ``handler``.
+
+    - If you want to handle the event on the client side without emitting anything to the server,
+        use ``js_handler`` with a JavaScript function handling the event.
+
+    - If you want to handle the event on the server with a subset or transformed version of the event arguments,
+        use ``js_handler`` with a JavaScript function emitting the transformed arguments using ``emit()``, and
+        use a Python ``handler`` to handle these arguments on the server side.
+
+        The ``js_handler`` can also decide to selectively emit arguments to the server,
+        in which case the Python ``handler`` will not always be called.
+
+    *Updated in version 2.18.0: Both handlers can be specified at the same time.*
+''')
+def register_event_handlers() -> None:
+    ui.button('Python handler') \
+        .on('click',
+            lambda e: ui.notify(f'click: ({e.args["clientX"]}, {e.args["clientY"]})'))
+
+    ui.button('JavaScript handler') \
+        .on('click',
+            js_handler='(e) => alert(`click: (${e.clientX}, ${e.clientY})`)')
+
+    ui.button('Combination') \
+        .on('click',
+            lambda e: ui.notify(f'click: {e.args}'),
+            js_handler='(e) => emit(e.clientX, e.clientY)')
+
+
 @doc.demo('Move elements', '''
     This demo shows how to move elements between or within containers.
 ''')
diff --git a/website/documentation/rendering.py b/website/documentation/rendering.py
index 1be7cabf..06f41bc9 100644
--- a/website/documentation/rendering.py
+++ b/website/documentation/rendering.py
@@ -72,6 +72,8 @@ def render_page(documentation: DocumentationPage, *, with_menu: bool = True) ->
                     documentation.extra_column()
         else:
             render_content()
+    with ui.column().classes('w-full p-4 items-end'):
+        ui.link('Imprint & Privacy', '/imprint_privacy').classes('text-sm')
 
 
 def _ancestor_nodes(node_id: str) -> List[str]:
diff --git a/website/header.py b/website/header.py
index bfdfc658..fb1c3054 100644
--- a/website/header.py
+++ b/website/header.py
@@ -1,3 +1,4 @@
+import os
 from pathlib import Path
 from typing import Optional
 
@@ -14,6 +15,11 @@ STYLE_CSS = (Path(__file__).parent / 'static' / 'style.css').read_text(encoding=
 def add_head_html() -> None:
     """Add the code from header.html and reference style.css."""
     ui.add_head_html(HEADER_HTML + f'<style>{STYLE_CSS}</style>')
+    if os.environ.get('ENABLE_ANALYTICS', 'false').lower() == 'true':
+        ui.add_head_html(
+            '<script defer data-domain="nicegui.io" src="https://plausible.io/js/script.hash.outbound-links.js">'
+            '</script>'
+        )
 
 
 def add_header(menu: Optional[ui.left_drawer] = None) -> None:
diff --git a/website/imprint_privacy.py b/website/imprint_privacy.py
new file mode 100644
index 00000000..d41ed678
--- /dev/null
+++ b/website/imprint_privacy.py
@@ -0,0 +1,48 @@
+from nicegui import ui
+from website.documentation.rendering import section_heading, subheading
+from website.header import add_head_html, add_header
+
+
+def create():
+    add_head_html()
+    add_header()
+    ui.page_title('Imprint & Privacy | NiceGUI')
+
+    with ui.column().classes('w-full p-8 lg:p-16 max-w-[1250px] mx-auto'):
+        section_heading('', 'Imprint')
+        subheading('Zauberzeug GmbH')
+        ui.markdown('''
+            Hohenholter Str. 43, 48329 Havixbeck, Germany
+
+            Represented by Rodion (Rodja) Trappe
+
+            Phone: +49 2507 3817, Email: info@zauberzeug.com
+        ''')
+
+        subheading('Registry entry')
+        ui.markdown('''
+            Registry court: Amtsgericht Coesfeld, Registry number: HRB 14215
+        ''')
+
+        subheading('Tax')
+        ui.markdown('''
+            Sales tax identification number according to §27a Sales Tax Act: DE286384205
+        ''')
+
+        section_heading('', 'Privacy Policy')
+        ui.markdown('''
+            We use [Plausible Analytics](https://plausible.io) to understand how you interact with our site.
+            Plausible Analytics is a privacy-first analytics tool
+            that does not use cookies or collect any personal data or personally identifiable information (PII).
+            All data collected by Plausible is aggregated and anonymized.
+
+            No other third-party analytics or tracking tools are used on this website.
+
+            These aggregated, non-identifiable usage statistics are processed on the basis
+            of our legitimate interest (Art. 6 (1)(f) GDPR) to analyze and improve our website.
+            You have the right to object to this processing at any time.
+            To exercise your right, please contact us at info@zauberzeug.com.
+
+            For more details on Plausible Analytics and its data policy,
+            visit <https://plausible.io/data-policy>.
+        ''')
diff --git a/website/main_page.py b/website/main_page.py
index 676ca2a6..ca1c2786 100644
--- a/website/main_page.py
+++ b/website/main_page.py
@@ -232,3 +232,5 @@ def create() -> None:
                         because of their great performance and ease of use.
                     ''')
             svg.face().classes('stroke-white shrink-0 w-[200px] md:w-[300px] lg:w-[450px]')
+        with ui.column().classes('w-full p-4 items-end text-white self-end'):
+            ui.link('Imprint & Privacy', '/imprint_privacy').classes('text-sm')


Test patch:
diff --git a/tests/test_events.py b/tests/test_events.py
index cdfa30a3..37ce80c6 100644
--- a/tests/test_events.py
+++ b/tests/test_events.py
@@ -191,3 +191,18 @@ def test_js_handler(screen: Screen) -> None:
     screen.open('/')
     screen.click('Button')
     screen.should_contain('Click!')
+
+
+def test_delegated_event_with_argument_filtering(screen: Screen) -> None:
+    ids = []
+    ui.html('''
+        <p data-id="A">Item A</p>
+        <p data-id="B">Item B</p>
+        <p data-id="C">Item C</p>
+    ''').on('click', lambda e: ids.append(e.args), js_handler='(e) => emit(e.target.dataset.id)')
+
+    screen.open('/')
+    screen.click('Item A')
+    screen.click('Item B')
+    screen.click('Item C')
+    assert ids == ['A', 'B', 'C']