stencil icon indicating copy to clipboard operation
stencil copied to clipboard

feat: add support for reactive controllers

Open WickyNilliams opened this issue 2 years ago • 10 comments

Prerequisites

Describe the Feature Request

Lit has added support for what it calls "Reactive controllers" in its latest major release. These are plain JS classes which conform to a known interface, and are able to hook into a component's lifecycle.

They are generic enough that they can be used/adapted to most/all frameworks, as they are not dependent on Lit at all (aside from interface definition, but that could be externalised). Therefore stencil could add support for these by implementing ReactiveControllerHost interface.

This could be considered a community standard, and would allow for sharing of functionality between stencil and lit (and any other frameworks that opt-into the standard), perhaps creating an ecosystem for pieces of reusable behaviour

Controllers are a mechanism for extracting both behaviour and state from a component in a reusable way.

Relevant links:

  • https://lit.dev/docs/composition/controllers/
  • https://lit.dev/docs/api/controllers/
  • https://github.com/lit/lit/blob/f8ee010bc515e4bb319e98408d38ef3d971cc08b/packages/reactive-element/src/reactive-controller.ts#L53
  • https://github.com/lit/lit/issues/1682

Describe the Use Case

There are currently no ways "official" ways to share functionality/logic/behaviour between components. At best you can do some manipulation of the component instance in a helper function, but this always feels fragile.

Reactive controllers offer a way to express a "has-a" relationship, and are composable. You can extract common behaviour and state for reuse between components and even frameworks.

Describe Preferred Solution

Stencil's base component class should implement ReactiveControllerHost. This could be as simple as:

class StencilBaseClass implements ReactiveControllerHost {
  controllers = new Set<ReactiveController>();

  addController(controller: ReactiveController) {
    this.controllers.add(controller);
  }
  removeController(controller: ReactiveController) {
    this.controllers.delete(controller);
  }

  requestUpdate() {
    forceUpdate(this);
  }

  connectedCallback() {
    this.controllers.forEach((controller) => controller.hostConnected());
  }

  disconnectedCallback() {
    this.controllers.forEach((controller) => controller.hostDisconnected());
  }

  componentWillUpdate() {
    this.controllers.forEach((controller) => controller.hostUpdate());
  }

  componentDidUpdate() {
    this.controllers.forEach((controller) => controller.hostUpdated());
  }
}

Just this would be enough to satisfy the support of reactive controllers.

Describe Alternatives

On slack, @snaptopixel described an alternative approach based around functional components: https://gist.github.com/snaptopixel/9dd86455a5791b65e9a0c0e576c097b6

However, this is entirely custom and stencil-specific, so would not be interoperable and is not framework agnostic.

Related Code

See the following gist: https://gist.github.com/WickyNilliams/79ee85ea370506ac6b16de1920f48e5e

This demonstrates the use of a number of custom controllers integrated into an example stencil component.

The MouseController is lifted verbatim from the Lit docs.

Perhaps, more interesting is that is uses the Task controller published by the lit team, as an example of how functionality can be shared across frameworks!

Additional Information

No response

WickyNilliams avatar Dec 01 '21 12:12 WickyNilliams

Thanks for putting this all in one place @WickyNilliams! I'm skeptical that interop with Lit is relevant, if anything it would be a nice-to-have. Granted it spurred the conversation and they have good ideas, I just don't think it should be the guiding principle here. One of the things that I always loved about Stencil was that they take the good parts from other frameworks and make them work together (ie: TSX from React, Decorators from Angular) in this case I think something like a @Controller decorator would be an ergonomic way to handle them.

snaptopixel avatar Dec 01 '21 14:12 snaptopixel

it's not interop with lit so much as interop with framework agnostic controllers. as you can see in my example, i used the lit-labs/task package instead of reinventing the wheel. there's a lot of power in that.

and subjectively, I also think it's a lot cleaner to keep logic out of render

take the good parts from other frameworks

controllers are a good part. i have experience with both stencil and lit, and i can attest to them being good in lit, and sorely lacking in stencil

WickyNilliams avatar Dec 01 '21 14:12 WickyNilliams

Thanks, FWIW, I do see the value in controllers/mixins and I think it's obvious that some semblance of them would be nice to have in Stencil for reasons you've mentioned. I just want to make sure we're laser focused on "what's good for Stencil"

snaptopixel avatar Dec 01 '21 14:12 snaptopixel

@WickyNilliams am I crazy or is this all we need?

https://gist.github.com/snaptopixel/82bc6b6e35c6a4a4c4127941242a7039#file-component-tsx-L94-L140

snaptopixel avatar Dec 01 '21 16:12 snaptopixel

Pretty much yeah. Though there is value in it being officially/natively supported.

You made me realise i missed the updateComplete promise. But that's also something that stencil should support imo, especially as everything is async in stencil anyway. It's very useful to be able to await next render complete before continuing with some logic, rather than having to juggle flags in lifecycles

this kind of thing is exactly what i meant in the original post when i said:

At best you can do some manipulation of the component instance in a helper function, but this always feels fragile.

host.connectedCallback = () => {
   this.controllers.map((ctrl) => ctrl.hostConnected?.())
  connectedCallback?.apply(host)
}

🙂

WickyNilliams avatar Dec 01 '21 16:12 WickyNilliams

feelsFragile !== isFragile though amirite, also I'm unclear on how the updateComplete promise is supposed to work...

snaptopixel avatar Dec 01 '21 18:12 snaptopixel

Of course, but I would say monkey patching is almost always fragile! Better to have a well defined mechanism for extension be that mixins or controller or whatever

WickyNilliams avatar Dec 01 '21 19:12 WickyNilliams

👋 Hey there

I think controller support for Stencil would be a boon to the library.

I've implemented a mixin which adds support to an arbitrary Element class: https://apolloelements.dev/api/libraries/mixins/controller-host-mixin/, as well as useController hooks for atomico and haunted, and similar bridges for FAST and hybrids, so this isn't about "interop with Lit", like OP mentioned above.

I hope taking a look at that can give your some inspiration.

bennypowers avatar Dec 06 '21 11:12 bennypowers

Adding my support for this too. Reactive Controllers offer an elegant pattern for code reuse in custom elements. Although they're somewhat new, we're already seeing amazing examples of what can be done with them without cluttering up our component's code base.

To demonstrate a real world example, I built a component-level localization library that uses the Reactive Controller pattern and I'd love to be able to share it with Stencil users as well.

claviska avatar Dec 07 '21 19:12 claviska

We implemented this same type of thing (we called behaviors) but having to manually implement all the lifecycle methods to call the behaviors' methods manually is cumbersome. Almost every component in our library has this in it:

  connectedCallback() {
    this.behaviors.connected();
  }
  
  componentWillLoad() {
    this.behaviors.willLoad();
  }

  componentDidLoad() {
    this.behaviors.didLoad();
  }

  componentDidRender() {
    this.behaviors.didRender();
  }
  
  disconnectedCallback() {
    this.behaviors.disconnected();
  }
  ...

We tinkered with the idea of adding a decorator to automatically do this but Stencil doesn't let us use our own decorator on the component class or constructor. And we found that the whole concept of componentWillLoad (and other lifecycle methods ) seem to be treeshaken out during the build if they aren't explicitly called in at least one component. So doing this in an "official way" would really appeal to us.

RobM-ADP avatar May 25 '22 17:05 RobM-ADP