Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: lwc:on Directive #92

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft

Conversation

gaurav-rk9
Copy link

This proposal adds a declarative mechanism to add a collection of event listeners to elements in an LWC template using a new directive lwc:on.

 Changes to be committed:
	new file:   text/0000-dynamic-events.md
Copy link

Thanks for the contribution! Unfortunately we can't verify the commit author(s): Gaurav Kochar <g***@s***.com>. One possible solution is to add that email to your GitHub account. Alternatively you can change your commits to another email and force push the change. After getting your commits associated with your GitHub account, refresh the status of this Pull Request.

 Changes to be committed:
	modified:   text/0000-dynamic-events.md
Why should we *not* do this? Please consider:

- implementation cost, both in term of code size and complexity
- whether the proposed feature can be implemented in user space
Copy link

@Templarian Templarian Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, except when lwc:component is used for dynamic component creation since the events are bound after the connectedCallback.

There are tradeoffs to choosing any path. Attempt to identify them here.


To add : Static Analyzability.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Static Analysis since it's a dynamic component we would run into similar issues as we do withlwc:component in that we cannot easily search for what components these create in the client code.

Copy link
Contributor

@nolanlawson nolanlawson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This proposal actually looks great to me. I think using lwc:on is much cleaner than the previous lwc:spread-based proposal.

One edge case not covered: are we planning to just use a simple Object.keys() on the supplied object? Or Object.getOwnPropertyNames? This matters if someone passes in e.g. a subclass that inherits properties from a superclass.

Another thing missing from this document is a review of what other frameworks are doing – how is Svelte, Vue, Lit, Solid, React, etc. handling this case?

text/0000-dynamic-events.md Outdated Show resolved Hide resolved
text/0000-dynamic-events.md Outdated Show resolved Hide resolved
text/0000-dynamic-events.md Outdated Show resolved Hide resolved
</template>
```

`lwc:on` will always be applied last. That means it will take precedence over whatever event listeners are declared in the template directly. In the above example, `x-child`, only one listener for event `foo` would be added and `childEventHandlers.foo` would be used for event handler .
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense to me. If something is dynamically applied, it "feels" like it should take precedence. So onfoo is essentially the "default" listener.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense that only one or the other would be bound to the element, when you're generally able to bind the same event more than once?

<template>
    <x-counter lwc:ref="counter" onfoo={handleFoo}></x-counter>
</template>
import { LightningElement } from 'lwc';
export default class extends LightningElement {
  handleFoo() {
    console.log('foo static')
  }
  renderedCallback() {
    this.refs.counter.addEventListener('foo', () => {
      console.log('foo dynamic');
    });
    this.refs.counter.dispatchEvent(new CustomEvent('foo'));
  }
}
> foo static
> foo dynamic

Copy link
Author

@gaurav-rk9 gaurav-rk9 Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thoughts on this were

  • Since we don't provide consumers with a way to explicitly remove these listeners, To execute multiple functions when an event is recieved, One can create a wrapper handler over these functions with added guarantee of order.
  • Fallback event listener seems to be of more value than an additional unconditional event listener
  • This would be consistent with how lwc:spread works.
  • This directive should not be a way for having multiple event listeners. If at some point we want to support multiple declarative event listeners, we should have a clean way to do that and it should not be a side effect of something else.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be consistent with how lwc:spread works.

This makes sense to me. Under the hood, it will be implemented similar to how lwc:spread is implemented, i.e. lwc:spread is:

props: {
  foo: $cmp.foo,
  ...$cmp.someObject
}

and lwc:on would be:

on: {
  foo: $cmp.foo,
  ...$cmp.someObject
}

(I'm skipping the binding and caching, but you get the idea.)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial thought was that onfoo={handleFoo} and lwc:on={ /* foo: handleFoo */ } should allow both, because having multiple event handlers is conceptually fine. But it's also reasonable to say "there's just one way of doing things, don't mix and match", to avoid user confusion and combinatorial complexity.


childEventHandlers = {
foo: function (event) {console.log('foo');} ,
bar: function (event) {console.log('bar');}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great if one of these examples used this, to demonstrate that the listeners are bound to the component instance.


### Event names

The keys of object passed to `lwc:on` should conform to the requirement set by DOM Event Specification. There would be no other constraint on the object's keys.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently LWC throws a compile-time error if you try to use onfooBar or onfoo-bar or other disallowed naming patterns. Are you proposing that we throw an error at runtime? Or warn? Or something else?

FWIW this was actually a point of controversy in the Custom Elements Everywhere benchmark, which LWC does not fully pass because we don't support these event names.

I think there's a case to be made that we should not be so strict here, and should just accept whatever event string name the user passes in. (Except maybe nonsensical values like the empty string '', non-strings, etc.) There are cases of web components "in the wild" that use odd naming conventions for their event names, and it would be nice to support that in lwc:on.

Copy link
Author

@gaurav-rk9 gaurav-rk9 Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding of why this is disallowed is based on Summary of rfc: declarative binding for non-standard event names.
we don't have any of those constraints here , so in long term I don't think we must have these restrictions.
However, I think when event names are known while authoring the component, the approach finalized in rfc: declarative binding for non-standard event names should be the recommended approach due to better static analyzibility.
So maybe we should have these restrictions until rfc: declarative binding for non-standard event names is implemented.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we should just support whatever the spec supports (i.e., DOMStrings), which is any valid string. I think the only thing we'd need to look out for are Symbols, as the spec allows either a string or a symbol as the key in an object.

Could you mention that we'll filter out Symbols?

Copy link
Author

@gaurav-rk9 gaurav-rk9 Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of the object iterators (Object.keys, for...in, etc.) ignore symbols, so any naive implementation of lwc:on will likely implicitly ignore symbols. If we want to check for symbols, we'd have to do so explicitly. That seems unnecessary, though, because they're not valid event names and probably nobody will ever try to use them.

Copy link
Author

@gaurav-rk9 gaurav-rk9 Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the implementation would be simpler, my thought was what would consumer expectation be, but now that I think about it , it does seem reasonable to say only own enumerable string-keyed properties will be considered.


Currently `lwc:spread` can be used to listen for standard events like `click` or `focus` by assigning event handlers to the `onclick` and `onfocus` properties. This works because the element on which these properties are applied inherits from HTMLElement. This implies that the event handlers would be bound to these elements itself and not the owner component as `onevent` directly on template does. Without `lwc:spread`, there is not a completely equivalent way of doing this. So modifying this behaviour would break components with no straightforward workaround. However it must also be noted that this difference is also a frequent source of confusion.

Implementing of this design would cause significant degradation of performance, since we would need to parse the object passed to `lwc:spread` in each render.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I doubt it would be significant, FWIW.

In my mind, the best argument against lwc:spread is 1) it's a potentially breaking change, and 2) its a potential point of confusion. lwc:on is much cleaner in my opinion because the intention is clear.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nolanlawson I'm fine with either approach, but was under the impression lwc:spread would complicate the existing compiled code. Ex: lwc:spread={simpleProps},

    props: {
      "name": "template",
      ...$cmp.simpleProps // <- This would need a runtime filter out .startsWith('on')
    },

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, it would complicate our code, but that's not the main argument against it IMO. Yes you're right that we would need a filter there.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated this section. I am not sure what qualifies as significant, I have removed it for now.

PR link and grammer

Co-authored-by: Nolan Lawson <[email protected]>
Copy link

Thanks for the contribution! Before we can merge this, we need @gaurav-rk9 to sign the Salesforce Inc. Contributor License Agreement.

 Changes to be committed:
	modified:   text/0000-dynamic-events.md

At present, `lwc:spread` can be used to listen for standard events like `click` or `focus` by assigning event handlers to the `onclick` or `onfocus` properties. This works because these properties are applied to the rendered element, meaning the event handlers are bound to the elements themselves, not to the owner component as `onevent` on the template does. This behavior might be unexpected for consumers. The distinction between the rendered element and the `LightningElement` component is an implementation detail. For consumers, it is simply an Lwc, and this behavior cannot be explained as the application of properties to Lwc. The proposed design would be a breaking change. However, any component that relies on the current behavior can be rewritten to accommodate the new design.

Although properties and event listeners use the same HTML attribute syntax, consumers understand their differences. They recognize whether a variable in an HTML template is a property, attribute, or event listener and reason about them separately. Combining them into a single directive does not enhance the developer experience. In fact, a combined directive might lead consumers to treat them as a unified concept, causing confusion. Additionally, the usage patterns of properties and event listeners are different, and a combined directive would complicate consumer code.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consumers understand their differences
a combined directive might lead consumers to treat them as a unified concept, causing confusion

counterpoint: consumers are already confused 😛

Copy link
Author

@gaurav-rk9 gaurav-rk9 Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we wouldn't want to confuse them more. 😃

</template>
```

`lwc:on` will always be applied last. That means it will take precedence over whatever event listeners are declared in the template directly. In the above example, `x-child`, only one listener for event `foo` would be added and `childEventHandlers.foo` would be used for event handler .
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My initial thought was that onfoo={handleFoo} and lwc:on={ /* foo: handleFoo */ } should allow both, because having multiple event handlers is conceptually fine. But it's also reasonable to say "there's just one way of doing things, don't mix and match", to avoid user confusion and combinatorial complexity.

### Caching

#### For static components, i.e. components declared in template directly using its selector
Since it is uncommon for event listeners to change after the start of the owner component's intial render, We can cache them to improve performance. Note that this is same as how `onevent` in template HTML works currently. For consumers, the implication of this would be that any changes made to the object passed to `lwc:on` after the first `connectedCallback` of owner component would cause no effect.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is essentially just sugar for doing addEventListener once and moving on? We wouldn't care/explode if a user did something weird like

{
  foo: function handleFoo() {
    this.removeEventListener('foo', handleFoo)
  }
}

Copy link
Author

@gaurav-rk9 gaurav-rk9 Jan 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this would do addEventListener once immediately after construction and move on. We don't care what the function does, we take the function input, wrap it into a new function that binds it to the owner component and and then add it as event handler to the element.

{
  foo: function handleFoo() {
    this.removeEventListener('foo', handleFoo)
  }
}

Here if the expectation is to remove the event listener added for event foo, this won't work as expected, because the event handler added won't be handleFoo, it will be a different function, whose reference the consumer doesn't have.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the event handler dynamically placed on the host doesn't seem like it is a required feature for these once attached.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants