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: Declarative host element #72

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
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
276 changes: 276 additions & 0 deletions text/0000-declarative-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
---
title: Declarative root element
status: DRAFTED
created_at: 2022-01-19
updated_at: 2022-01-19
champion: Nolan Lawson (nolanlawson)
pr: https://github.com/salesforce/lwc-rfcs/pull/72
---

# Declarative root element

## Summary

Provides a declarative way to apply reactive attributes, classes, and event listeners to the root (host) element.

## Basic example

In a component's template HTML file:

```html
<template>
<lwc:root
class="static-class"
data-foo={dynamicAttribute}
onclick={onRootClick}
lwc:spread={otherAttributes}
></lwc:root>
<h1>Hello world</h1>
</template>
```

Result:

```html
<x-component class="static-class" data-foo="foo" data-other="other">
#shadow-root
<h1>Hello world</h1>
</x-component>
```

Classes, attributes, and event listeners are applied from the `<lwc:root>` element to the
root `<x-component>` element. (The event listener is not shown above.)

## Motivation

A common pattern in LWC components is something like this:

```js
export default class extends LightningElement {
connectedCallback() {
this.template.host.classList.add('my-class');
}
}
```

Developers may want to add a class, set an attribute, or add an event listener to the root (host) element
of the component. Today there is no declarative way to do this, so they resort to doing it manually in the
`connectedCallback`, `constructor`, or `renderedCallback`.

This has several downsides:

1. It does not work well with SSR, where it would require shims for DOM APIs like `classList`, `setAttribute`, etc., where `connectedCallback` timings [may differ](https://github.com/salesforce/lwc/issues/3009) between SSR and DOM, and where `renderedCallback` doesn't fire at all.
2. It is not easy to make it reactive, e.g. to set a class that updates based on a `@track`ed property.

Prior art in other frameworks:

- Stencil: [`<Host>` functional component](https://stenciljs.com/docs/host-element)
- Angular: [`@HostBinding`](https://angular.io/api/core/HostBinding)
- FAST: [Host directive template](https://www.fast.design/docs/fast-element/using-directives/#host-directives)
- Lit: [Nonexistent, but considered](https://github.com/lit/lit/issues/1825)

## Detailed design

The `<lwc:root>` element is a synthetic element defined in a component's template HTML file. Similar to [dynamic components](https://github.com/salesforce/lwc-rfcs/pull/71), it does not actually render in the DOM.
Copy link
Member

Choose a reason for hiding this comment

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

Will this element be available for light DOM as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. Clarified: 815466f


```html
<template>
<lwc:root class="foo"></lwc:root> <!-- Doesn't actually render -->
</template>
```

However, many attributes and directives that can be applied to a normal `HTMLElement` can be applied to `<lwc:root>`. These include:

- Standard [global HTML attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes), such as `class` and `tabindex`
Copy link

@jhefferman-sfdc jhefferman-sfdc Jan 20, 2023

Choose a reason for hiding this comment

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

This sounds very good, ones that are important to us include aria-xyz, role, size, id

- Custom HTML attributes, such as `data-*`
- Event listeners, such as `onclick` or `onfocus`
- The `lwc:spread` directive

Other directives that apply to normal `HTMLElement`s, such as `lwc:inner-html`, `lwc:ref`, and `key`, are not supported.

A bare `<lwc:root></lwc:root>` with no attributes/listeners/directives is allowed, but effectively does nothing.

### Restrictions

#### Placement

A `<lwc:root>` element must be placed at the top level (root) of a `<template>`, and may not be preceded by other elements:

```html
<!-- Valid -->
<template>
<lwc:root></lwc:root>
<div></div>
</template>
```

```html
<!-- Invalid -->
<template>
<div>
<lwc:root></lwc:root>
</div>
</template>
```

```html
<!-- Invalid -->
<template>
<div></div>
<lwc:root></lwc:root>
</template>
```

Comments are allowed to precede `<lwc:root>`:

```html
<!-- Valid -->
<template>
<!-- Comment -->
<lwc:root></lwc:root>
</template>
```

However, in the case of `lwc:preserve-comments`, comments may not precede `<lwc:root>`:

```html
<!-- Invalid -->
<template lwc:preserve-comments>
<!-- Comment -->
<lwc:root></lwc:root>
</template>
```

#### Contents

The `<lwc:root>` element may not have any contents other than whitespace and comments:

```html
<!-- Valid -->
<template>
<lwc:root>

</lwc:root>
</template>
```

```html
<!-- Valid -->
<template>
<lwc:root>
<!-- Comment -->
</lwc:root>
</template>
```

However, in the case of `lwc:preserve-comments`, comments inside of `<lwc:root>` are not allowed:

```html
<!-- Invalid -->
<template lwc:preserve-comments>
<lwc:root>
<!-- Comment -->
</lwc:root>
</template>
```

This restriction is why `lwc:inner-html` is not supported on `<lwc:root>`.

### Timing

In terms of timing, any attributes added to or removed from the root element should follow the timing applied
to elements inside of the template:

```html
<template>
<lwc:root class={clazz}></lwc:root>
<div class={clazz}></div>
</template>
```

In other words, in the above example, if `clazz` changes, then the root element's `class` attribute should be changed
in the same tick as when the `<div>`'s `class` changes.

The above also applies to the timing of when event listeners are attached using `on*`.

No guarantees are made about the ordering of changes made to `<lwc:root>` versus elements inside of the template.
Copy link
Member

Choose a reason for hiding this comment

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

ordering of changes made to <lwc:root>

Is this referring to the ordering of how classes, event handlers, etc. are applied from <lwc:root> to the host element?

versus elements inside of the template

Does this mean that there could be a difference in the order the classes, event handlers, etc are applied on the host vs to elements within the host?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, I didn't think of that. Updated! bbbc288


### Precedence

Attributes and event listeners added using `<lwc:root>` may conflict with those added by the parent component:

```html
<!-- parent.html -->
<template>
<x-child class="foo"
data-foo="bar"
onclick={onClick}
lwc:spread={others}>
</x-child>
</template>
```

```html
<!-- child.html -->
<template>
<lwc:root class="quux"
data-foo="toto"
onclick={onClick}
lwc:spread={others}>
</lwc:root>
</template>
```

In cases of conflict, the following rules apply:

- `lwc:spread` precedence applies [as normal](https://github.com/salesforce/lwc-rfcs/pull/52) within the `<lwc:root>` element itself.
- For event listeners such as `onclick`, both event listeners are attached.
- For the `class` attribute, strings are concatenated with a single whitespace (`" "`) character (e.g. `"foo quux"` in the above example). No attempt is made to deduplicate duplicate strings.
pmdartus marked this conversation as resolved.
Show resolved Hide resolved
- For all other attributes, the parent overrides the child's attribute (e.g. `data-foo` would be `"bar"` in the above example).

The reason for this is to allow a component to set defaults for its root's behavior, while still allowing consumers to override those defaults.

## Drawbacks

Implementing this feature does require additional complexity, and moves us further away from standard custom element syntax.

## Alternatives

### Calling it "host" instead of "root"
pmdartus marked this conversation as resolved.
Show resolved Hide resolved

Other frameworks call this feature "host." And there is some precedent in LWC, in that [light DOM scoped styles](https://github.com/salesforce/lwc-rfcs/pull/50) allow for styling the root of a light DOM component using `:host` in `*.scoped.css`.

However, this proposal prefers "root" to "host," because "root" is a more generic term that does not imply shadow DOM. This
makes it clear that, for example, a child light DOM component cannot set attributes on a parent shadow DOM component by using this technique. (Whereas a child light DOM component can indeed style its parent's root node using `:host` in unscoped CSS.)

### Placing attributes on the root `<template>` tag.
Copy link
Contributor

Choose a reason for hiding this comment

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

What about replacing the template tag with <lwc:root>?

e.g.

<lwc:root class="klass">
  <h1>regular elements</h1>
</lwc:root>

Of course, the regular <template> will still be supported.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To me, this seems like too much of a drastic change to the existing system. Developers are very accustomed to putting <template> at the top.

It also raises questions of how we would need to support lwc:render-mode, lwc:preserve-comments, etc. on <lwc:root>. Whereas if the two are separated, then it's clear which directives apply to which tag.


Historically in LWC, `<template>` is used to designate a reusable tree of HTML, e.g. in `for:each` and [scoped slots](https://github.com/salesforce/lwc-rfcs/pull/63). The root `<template>` does not actually refer to the root/host element.

Also, because `<lwc:root>` supports attributes and directives that generally would apply to normal `HTMLElement`s, it makes
sense to represent it as a pseudo-normal HTML element.

### Supporting `lwc:ref`

Supporting `lwc:ref` is technically feasible, but doesn't make much sense, as there are already alternatives. In shadow DOM, developers can use `this.template.host`, and in light DOM, they can use `this`.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Technically this in light DOM is not exactly the same as what you'd get with lwc:ref, since the former is the internal view of the component and the latter is the external view. But it's ~99% the same. You can do this.classList, this.children, this.setAttribute, etc., and it works as expected.


Also, the whole point of this feature is to avoid needing programmatic access to the root/host element. So adding `lwc:ref` would be counter-productive.

## Adoption strategy
Copy link
Member

Choose a reason for hiding this comment

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

I would go a step further and deprecate all APIs exposed on the LightningElement that are covered by this proposal.

The main motivation would be to remove those programmatic APIs on the engine server. Removing programmatic host element mutation would certainly greatly simplify the SSR logic and would enable us to get rid of some of the DOM APIs polyfills we have to maintain for SSR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To clarify, are you proposing that we remove all DOM-like APIs on the this exposed to component authors? E.g.:

renderedCallback() {
  console.log(this.children)
  console.log(this.getAttribute('class'))
  console.log(this.querySelector)
}

?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I was wondering if we should deprecate all those DOM-like APIs. That said there are some APIs like dispatchEvent that would certainly have to stay on the LightningElement prototype.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My understanding was that, someday, we might converge the "bridge element" and "lightning element" classes. It would be a nice simplification from my POV if this just referenced the element (as it does in other web component frameworks like Lit). In that world, all of these APIs would "just work."

Copy link
Member

Choose a reason for hiding this comment

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

I was always under the impression that the long-term goal should be to merge the LightningElement instance with the host element.

But I am now questioning this, because of the tension it creates with SSR. In a world where we can achieve this merge. Does it mean that we would have to polyfill all the DOM APIs available on the host element to make LWC components compatible with SSR?

Something that I really about this proposal is that we provide a declarative alternative to those DOM APIs. Those APIs enable developers to write isomorphic components without having to worry about the environment they are running in. Another added benefit of this declarative approach is that it enables the LWC compiler to optimize the usage of those APIs.

This is just food for thought, I don't have yet a formal opinion on this.


This proposal can be adopted as a net-new feature and does not have backwards-compatibility implications. `lwc:root` is
not a pre-existing built-in HTML element, and it's extremely unlikely that anyone is creating runtime elements with this name.

It may be possible to write codemods that look for simple usages of e.g. `this.template.host.classList.add('foo')`
pmdartus marked this conversation as resolved.
Show resolved Hide resolved
and replace it with `<lwc:root>`. This may be too complex to be worthwhile, though.

# How we teach this

This feature is LWC-specific (not based on an existing web standard) and will have to be taught as such. The existence of
similar mechanisms in other frameworks (e.g. Stencil and Angular) does provide some reference points that help with teaching.

However, the fact that, for the most part, we can just say "`<lwc:root>` behaves like a normal element" makes it easier to teach this. Developers can reuse their existing knowledge to understand how it works.

# Unresolved questions

None at this time.