[WICG/webcomponents] A better solution for ElementInternals (Issue #1036)

As I'm using this in the real world because all browsers recently support the `ElementInternals` feature, I find that element "internals" are just too difficult to keep internal, especially when trying to use the feature with (imo idiomatic) JavaScript patterns like class-factory mixins. For now I simply settle with public properties.

Example:

```js
function WithShadow(Base) {
 return class WithShadow extends Base {
  shadowMode = "open"

  constructor(...args) {
   super(...args)

   this.internals ??= this.attachInternals()

   if (!this.internals.shadowRoot) this.attachShadow({mode: this.shadowMode})
  }
 }
}

class MyEl extends WithShadow(HTMLElement) {
  shadowMode = 'closed'
  connectedCallback() {
    console.log(this.internals.shadowRoot)
  }
}
```

Will someone decide to mess with `element.internals`? Who knows. If they break something, oh well.

# The problem with `this.attachInternals()`

This is still not ideal, because it means this mixin _**is incompatible with other mixins or classes that do not use `this.internals` as a convention**_. Any classes that store internals on some other property will cause a runtime error.

A `protected` feature in JavaScript is nowhere near being in the picture, and may never be at this pace, so that's not something we can simply tell people to wait for. And even if it existed, the same issue exists: not all classes will be standardized to use the same property, so the runtime error can still happen when using the same pattern with a protected field.

**The main problem right now is that the pattern I choose for making internals shared across classes can conflict with some other author's existing pattern, making classes/libraries incompatible, and a `protected` feature for JavaScript cannot fix that.**

# A better solution today?

I'm starting to think that a standardized callback for internals would be a better way to expose internals and keep them encapsulated, as described to some extent in

- https://github.com/WICG/webcomponents/issues/758

It works better because then there's a standard named member that all custom element classes can rely on with which they can receive internals without leaking to public.

With a callback, there is no conflict of property naming, each class can use a `#private` field without issue, like so:

```js
class CoolElement extends HTMLElement {
  #internals
  
  internalsCallback(internals) {
    this.#internals = internals
    this.#doSomethingWithInternals()
  }
  
  #doSomethingWithInternals() { console.log(this.#internals) }
}

class CoolElementWithMoreFeatures extends HTMLElement {
  #stuff
  
  internalsCallback(internals) {
    super.internalsCallback(internals)

    this.#stuff = internals
    this.#doSomethingWithStuff()
  }
  
  #doSomethingWithStuff() { console.log(this.#stuff) }
}

class CoolElementWithEvenMoreFeatures extends HTMLElement {
  #things
  
  internalsCallback(internals) {
    super.internalsCallback(internals)

    this.#things = internals
    this.#doSomethingWithThings()
  }
  
  #doSomethingWithThings() { console.log(this.#things) }
}

const el = new CoolElementWithEvenMoreFeatures()

el.#internals // error
el.#stuff // error
el.#things // error

// either this is allowed:
el.internalsCallback(new MyMockInternals())

// or maybe the browser enforces a special invariant that the method cannot be called after construction:
el.internalsCallback(something) // runtime error
```

# A better solution in the decorated future?

## Extending the previous idea to enforce the invariant

How can that invariant idea be enforced? It would be easy to enforce with `document.createElement()` because it can easily run logic after construction to delete the `internalsCallback` method. But with usage of `new`, the engine would need a special non-standard way to run logic after construction to ensure that the method is deleted after `new MyElement` is finished, or some other special non-standard JS behavior (not sure if that's possible across browsers).

When decorators reach stage 4 (looks like they have a very good chance of doing so now, but browser implementations are going really slow in this area) then another idea for enforcing the invariant is to require usage of a decorator, because decorators can always add logic that runs _**after the user's contructor**_.

Example:

```js
@customElements.withInternals
class MyEl extends HTMLElement {
  internalsCallback(internals) { ... }
}
```

where `customElements.withInternals` is a decorator provided natively by the browser's `customElements` API, and where `internalsCallback` will be called during construction only if the decorator is used, and where the decorator has the final say in finishing the construction so it can delete `internalsCallback` (and if for some reason it cannot be deleted, throws an error).

With `new.target`, the decorator can ensure that in a hiearchy of such decorated classes, only the outermost class performs the finalization:

```js
@customElements.withInternals
class MyEl extends HTMLElement {
  internalsCallback(internals) { ... }
}

@customElements.withInternals
class BetterEl extends HTMLElement {
  internalsCallback(internals) { ... }
}

new BetterEl() // internalsCallback is deleted only after the `BetterEl` constructor, not after `MyEl`.
```

Could such an idea be viable?

## Alternatively with a method decorator

Another idea is maybe there's a way to do it with a built-in decorator that injects internals directly to a method:

```js
class MyEl extends HTMLElement {
  @customElements.withInternals
  myArbitrarilyNamedInternalsHandler(internals) { ... }
}
```

# TLDR

These solution ideas keep the internals **actually protected** (just not in the form of a `protected field` which would still have the naming problem). With these ideas, a _user of an element_ cannot get the internals from the outside (although they can try to mock it in the case without the invariant, and element authors can guard against this if they wish), while all classes that all custom element authors could possibly write will have a standard way of sharing protected internals without conflicting patterns.

It is possible to add these new solutions later, while leaving `this.attachInternals` so that it would still work (if not deprecating it and documenting so in places like MDN (and maybe even eventually removing it)).

With the new solution in place, custom element authors begin to stop using `this.attachInternals` (new elements would be designed with the new API up front, and existing libraries would start to convert to the new solution over time). Precendence for this exists with Custom Elements v0, and DOM Mutation Events which are deprecated although all browsers still have the API.

-- 
Reply to this email directly or view it on GitHub:
https://github.com/WICG/webcomponents/issues/1036
You are receiving this because you are subscribed to this thread.

Message ID: <WICG/webcomponents/issues/1036@github.com>

Received on Wednesday, 15 November 2023 22:18:50 UTC