- From: Joe Pea <notifications@github.com>
- Date: Wed, 15 Nov 2023 18:02:58 -0800
- To: WICG/webcomponents <webcomponents@noreply.github.com>
- Cc: Subscribed <subscribed@noreply.github.com>
- Message-ID: <WICG/webcomponents/issues/1029/1813654982@github.com>
One thing I haven't gotten to write my thoughts on yet is type safety. I first created `element-behaviors` before I adopted TypeScript. In plain JavaScript, these dynamic mixin-like patterns are fine. First, let me show problems in TypeScript with the the `is=""` attribute: it does not allow the same type safety as non-builtin-extends custom elements do (and similar with custom attributes, element behaviors, and enahncements): First, here is a sample with `is=""`: ```ts class CoolButton extends HTMLButtonElement { foo = "bar" // suppose we add a new property "foo", and values from a "foo" attribute get assigned to it. } customElements.define('cool-button', CoolButton, {extends: 'button'}) const button = document.createElement('button') // TypeScript infers `button` to be `HTMLButtonElement`. console.log(button instanceof HTMLButtonElement) // true console.log(button instanceof CoolButton) // false (!) // Here TypeScript does not change the type of the `button` variable from `HTMLButtonElement` to `CoolButton`. // And, umm, is the element supposed to be upgraded when this attribute gets set? button.setAttribute('is', 'cool-button') console.log(button instanceof HTMLButtonElement) // still true console.log(button instanceof CoolButton) // still false (!), and large ding against customized-builtins const newVariable: CoolButton = button // Type error! (good in this case) console.log(button.foo) // Type error! (good, because right now it is undefined) ``` Paste that into your browser console and hit enter after removing the type annotations. Then check out the [type errors in TS playground](https://www.typescriptlang.org/play?ssl=20&ssc=1&pln=20&pc=81#code/MYGwhgzhAEDCD28QCECuAXd8B20CmAHuntgCYwASAKgLIAyamOAoiHgLYnrQDeAUNGgAzRNAC80AEQAjMACdJ0APRLoEVAAcN8CHmgB3PWFKloYaNjz7oGufA1456AJ5SR8SQBozZaADcwEFQ8GCE7djM3REUwTDkAS2kMPQBzPG5ICHiUy1MsaHj0ADo+AF8+YFQILHZWDi4IItI8IXjLAAoAcmBEEABaJKZsTu8EJEYsbG8eQmIyCAAuaE7Byc7SgEo+Cpxq6FWccWhSeErObGLgOTxYvDrz9C6D4Y3lVSpnBwBlK-iNbjaQkcMAABs8QdB8tI9CDqPQJiw2A8QSUdtgIEg8EUQPAUu1ngV0egwNhgHh4EJoHCGBhJvcuK8VJC5ME0Ri2NjcfjaYc2tUSWSKXBegjsIzVEJArpoO0AIRbPhMiiOPQfb6-f7HeAhCzwbjAAAWJLSkINehcDmgQvQZugYJ52AhAQSYGkbGE4Tt1NF9IuEPyILGKAdKMVqgAgmRvKh2OxvPEYDa9HgkVw1JptLo8vB9npNCk5MY8KZ9GbcDaE2Y4olktA0ugYLp0AB+PjPIpN8PVwZ4LoJkbLHpIAYOzoKnrozGcvEEvnE0nkyneh2+9DitToeIgEDM1kT9lYnEzh2E-kLoVB0Xr6pbneSkDSuUbbwk0zgOQm0htFJmFJgOfQJU1TwOw8QAF7FiOW6bui2z7twlj6AAavI8SumwSyXieEgEkyarJnIdhyLKMopIgphtKalbAJAeDjrsU5HtyQxFO4674fghHwMRpHkd40I0VUegJCkBoIfA1iFAUMCoGQLRtMWGxAA). Output: ``` true false true false undefined ``` (This shows a significant problem with `is=""`.) Now, let's suppose we do things differently so that `is=""` actually works: ```ts class CoolButton extends HTMLButtonElement { foo = "bar" // suppose we add a new property "foo", and values from a "foo" attribute get assigned to it. } customElements.define('cool-button', CoolButton, {extends: 'button'}) document.body.insertAdjacentHTML('beforeend', '<button is="cool-button">btn</button>') const button = document.querySelector('button')! // TypeScript infers `button` to be `HTMLButtonElement`. console.log(button instanceof HTMLButtonElement) // true console.log(button instanceof CoolButton) // true const newVariable: CoolButton = button // Type error! (bad, it actually is a CoolButton!) console.log(button.foo) // Type error! (bad, the property exists!) ``` Try it in console after stripping types, and here's the [TS playground showing type errors](https://www.typescriptlang.org/play?#code/MYGwhgzhAEDCD28QCECuAXd8B20CmAHuntgCYwASAKgLIAyamOAoiHgLYnrQDeAUNGgAzRNAC80AEQAjMACdJ0APRLoEVAAcN8CHmgB3PWFKloYaNjz7oGufA1456AJ5SR8SQBozZaADcwEFQ8GCE7djM3REUwTDkAS2kMPQBzPG5ICHiUy1MsaHj0ADo+AF8+YFQILHZWDi4IItI8IXjLAAoAcmBEEABaJKZsTu8EJEYsbG8eQmIyCAAuaE7Byc7SgEo+PlJ4Ss5sYul4Umcitt0nAEFSACswYC5qei7pFvg5PBJSEeWAHlWOAKEDEkh6SAGGEmkgAfNJ0Ng-kpAdgYZ0NtBtj1sNVoCjxNBdvsuEUAI7BOTOADKeDYwCwcleUJw6OUqiozgcVOACQ03DaQkcMAABijhdB8m9oMLngxmdg6gd0MKSvEhNB2gBCFEY9AACzs1k6hXwBHi1QgnSxOAgSDwRRA8BS7XxF3QYGwj3g6tlExYbCVGJUErkwQqNrtDqdLvlBRx7s9eG9cF6fuwQdU6FDeGt8YsVgAavJ4mBpGwlmMULGJPjgxyHPg5HY5JqNbJSN4TQ90KhAiBXObIpW05qtuGcZHHc6UUV3BnoPW9I5m62XcZvPq9LZ7I4XKbzegIKOgA). A similar problem can manifest itself with custom attributes, behaviors, and enhancements, when they both listen to additional attributes on an element and they observe those attribute changes via JavaScript property values (very common in custom element libraries, and libraries similar to custom element libraries (customattributes/behaviors/enhancements) where similar patterns are replicated). For sake of example, suppose we define a behavior (but similar applies with custom attributes and enhancements, just varying syntax) that listens to a "foo" attribute on its host element, and the way that it sets this up is via a decorator that does two things: observes the "foo" attribute and observes the "foo" property on the host element (taking into consideration that maby frameworks today bypass attributes and set properties directly on custom elements and therefore the behavior needs to observe properties because frameworks are setting JS properties): ```html <body> <div has="coolness" foo="bar"></div> </body> ``` ```js @behavior('coolness') class Coolness extends ElementBehavior { @attribute @receiver foo = "initial" connectedCallback() { // Log this.foo anytime it changes (which will have been due to either the host's "foo" attribute changing, or the host's "foo" property changing). createEffect(() => console.log(this.foo)) } } ``` where `@behavior` sets up `observedAttributes` containing `"foo"` due to the `@attribute` decorator, `@behavior` installs a getter/setter on the host element for the property `"foo"` due to the `@receiver` decorator ([real-world example](https://github.com/lume/lume/blob/66dd20fa27ee66fb93c989965467d83b4a6bf177/src/behaviors/mesh-behaviors/materials/PointsMaterialBehavior.ts#L11-L15)), and `createEffect` is an API from [Solid.js](https://solidjs.com/) that makes a function re-run when properties used inside of it change value due to the `@attribute` decorator making those properties read and write from a Solid.js signal ([real-world example](https://github.com/lume/lume/blob/66dd20fa27ee66fb93c989965467d83b4a6bf177/src/behaviors/mesh-behaviors/materials/PointsMaterialBehavior.ts#L24-L32)). We start to experience some of the same problems with type safety with the following added code: ```js const div = document.querySelector('div') // TypeScript infers this to be HTMLDivElement div.foo = "bar" // Type Error, but it works! Logs "bar" to console. ``` It is important for a behavior to be able to observe properties, not just attributes, due to today's frameworks allowing both attributes and properties to be set via delarative templating systems, with the preference being on JS properties. In plain HTML, the following will cause the behavior to map the host element attribute value to the behavior's JS property: ```html <body> <div has="coolness" foo="bar"></div> </body> ``` In Lit, the following template set an attribute, and so the behavior with "foo" in `observedAttributes` (due to the `@attribute` decorator) will catch the value and map it to its `foo` property: ```js return html` <div has="coolness" foo="bar"></div> ` ``` Lit has syntax for setting properties on an element, bypassing the attributes, so we need to write robust implementations that can handle this: ```js return html` <div has="coolness" .foo="bar"></div> <some-custom-element has="coolness" .foo="bar"></some-custom-element> ` ``` where `some-custom-element` might even be a 3rd-party custom element that has a particular `foo` JS property but no corresponding `foo` attribute. Such libraries exist: https://iogui.dev [This iogui doc page](https://iogui.dev/io/#path=Docs,Deep%20Dive,Nodes%20and%20Elements) states: > Note: Io-Gui templates do not set HTML attributes - only properties are set. [Solid's`html` template tag](https://github.com/solidjs/solid/tree/main/packages/solid/html) also has a similar feature, and it defaults to JS properties for custom elements because custom elements typically use JS properties for their reactivity: ```js const elements = html` <div has="coolness" foo="bar"></div> <!-- sets an attribute by default --> <some-custom-element has="coolness" foo="bar"></some-custom-element> <!-- sets a property by default --> <div has="coolness" prop:foo="bar"></div> <!-- explicitly set property --> <some-custom-element has="coolness" attr:foo="bar"></some-custom-element> <!-- explicitly set attribute --> ` ``` In today's landscape custom attributes, behaviors, enhancements, or any similar concept, need to be robust and handle both attributes and properties. In the above examples, the `foo` attributes and properties were not defined on the elments, but only in the behavior. You could, in practice, define a certain custom element that can have a certain set of behaviors on it, and you can augment the type definition so that the element class will have all possible properties of all possible behaviors as optional properties, which is very ugly ([real-world example](https://github.com/lume/lume/blob/b12090865e612d591706f8ab48d4905efc571d51/src/meshes/Mesh.ts#L136)). This is fairly bad because it means a library (f.e. Lume) has to define up front what possible behaviors are known to be placeable onto a `<lume-mesh>` element in order to get type safety. What about 3rd-party authors? Now they need to augment the type of the `Mesh` class using this similar sort of type hack in their extending libraries, which is very hacky. In Lume's current state, when you write this code in VS Code: ```js const mesh = document.querySelector('lume-mesh') mesh. ``` then VS Code will begin to show possible auto-completions, which will include a list of all possible properties even if they are properties from behaviors that are not currently added to the element: <img width="523" alt="Screenshot 2023-11-15 at 5 26 31 PM" src="https://github.com/WICG/webcomponents/assets/297678/66135b40-0a6b-486a-8500-8095bef88f00"> What if the `<lume-mesh>` element `has="phong-material"` but does not `has="physical-material"`? The `clearcoat` property is not applicable with a phong material. Etc. This is not the best developer experience. Besides that, if new behaviors are added for use on `<lume-mesh>` elements, and someone forgot to augment the `Mesh` class with the additional properties, TypeScript will show a type error for the missing properties when attempting to use them. But this example of pre-defined behaviors in Lume is not even a usable concept with behaviors that are generic to be applied onto any element. Imagine if, for example, a behavior/attribute/enhancement author goes and augments the `HTMLElement` base class with all the possible properties of all their behaviors. What a mess that would be! # Alternatives I am contemplating to add a new pattern to Lume, where instead of behaviors being added via the `has=""` attribute, they will be added as "behavior elements" that are behaviors to their immediate parent in the DOM. This HTML, ```html <lume-mesh has="phong-material" color="red"></lume-mesh> <lume-mesh has="physical-material" clearcoat="0.7"></lume-mesh> ``` will change to this: ```js <lume-mesh> <phong-material color="red"></phong-material> </lume-mesh> <lume-mesh> <physical-material clearcoat="0.7"></physical-material> </lume-mesh> ``` This is a lot cleaner because: - type definitions are scoped to custom element definitions. `document.querySelector('physical-material')!.clearcoat = "0.8"` has no type error in TS. - there is no more need to augment element classes with additional possible properties that really only exist when certain behaviors are added - this is compatible with all of today's web framework composition patterns (slots, props.children, etc) where behaviors are not. This next example uses JSX syntax, and composes "behavior elements" (for sake of naming them to compare then with "element behaviors") in an idiomatic way that all of today's frameworks support (with varying syntax not necessarily as follows): ```jsx function MyMesh(props) { return <lume-mesh {...props}><slot></slot></lume-mesh> } function ThirdPartyDiamondMaterialAndGeometry() { return <> <special-glass-material index-of-refraction="0.8"></special-glass-material> <diamond-geometry ...></diamond-geometry> </> } function MyApp() { return <MyMesh position="1 2 3" rotation="4 5 6"> {/*Compose any material and geometry combination into the MyMesh component.*/} <ThirdPartyDiamondMaterialAndGeometry /> </MyMesh> } ``` This composition is not possible with most of today's templating systems because most of the client-side templating systems today do not compose components into attributs, only into children. Some templating systems like Handlebars (or basically anything that does string interpolation on the server side) are able to compose into attributes. This new pattern that I'll be adding to Lume will be a lot easier to work with (easy to define type defintions simply as properties on a class, and without a bad intellisense story), and a lot easier to compose in React/Vue/Svelte/Solid/Angular/etc. # TLDR I think that the concept of element behaviors, custom attributes, and element enhancements, still have a place. They will be more useful in cases that do not listent to additional arbitrary JS properties (as far as type checking), or that listen only to attributes (but then `setAttribute(...)` is just like `Record<string, string>` which isn't as useful). The new pattern I'm moving to, does not solve the problems that `is=""` solves. For example, "behavior elements" will not work in this case: ```html <table> <tr> <behavior-for-the-tr></behavior-for-the-tr> <td></td> </tr> </table> ``` The parser will remove the `<behavior-for-the-tr>` and the result will be: ```html <behavior-for-the-tr></behavior-for-the-tr> <table> <tbody> <tr> <td></td> </tr> </tbody> </table> ``` which means the `behavior-for-the-tr` will have a different parent "host" element that it will apply to. So, I think that custom attributes/behaviors/enhancements can still be useful for solving some cases, especially the ones where custom elements are impossible to use, but generally speaking I would like to move other use cases into the children-as-behaviors concept for better type safety and composability. Upsides of attributes/behaviors/enhancements are that it is easy to style them with CSS with attribute selectors. Styling elements with certain "behavior elements" is a little more cumbersome. <details> <summary>Also in Lume we have a goal to be able to move our Lume-specific features out of HTML and into CSS, f.e.</summary> converting either of these ```html <lume-mesh has="phong-material" color="red"></lume-mesh> <lume-mesh has="phong-material" color="red"></lume-mesh> <lume-mesh has="physical-material" clearcoat="0.7"></lume-mesh> <lume-mesh has="physical-material" clearcoat="0.7"></lume-mesh> ``` ```js <lume-mesh><phong-material color="red"></phong-material></lume-mesh> <lume-mesh><phong-material color="red"></phong-material></lume-mesh> <lume-mesh><physical-material clearcoat="0.7"></physical-material></lume-mesh> <lume-mesh><physical-material clearcoat="0.7"></physical-material></lume-mesh> ``` into something like this: ```html <lume-mesh class="one"></lume-mesh> <lume-mesh class="one"></lume-mesh> <lume-mesh class="two"></lume-mesh> <lume-mesh class="two"></lume-mesh> <style> .one { --material: phong; --material-color: red; } .two { --material: physical; --material-clearcoat: 0.6; } </style> ``` - https://github.com/lume/lume/issues/159 I think that we'll have some sort of system that applies "behaviors" from CSS, then overrides with the attribute values from "element behaviors" or "behavior elements". I'm not sure which pattern is better for it. Maybe if the concept of a behavior is totally abstracted so that `element.behaviors` provides the same JS API regardless if the behaviors are attributes or elements, then it might not matter. </details> -- Reply to this email directly or view it on GitHub: https://github.com/WICG/webcomponents/issues/1029#issuecomment-1813654982 You are receiving this because you are subscribed to this thread. Message ID: <WICG/webcomponents/issues/1029/1813654982@github.com>
Received on Thursday, 16 November 2023 02:03:05 UTC