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

Web Components: Discussion about how to use Crank.js for custom elements #47

Open
mcjazzyfunky opened this issue Apr 22, 2020 · 12 comments
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@mcjazzyfunky
Copy link
Contributor

mcjazzyfunky commented Apr 22, 2020

Wouldn't it be great to implement custom elements in a "crankish" 😄 way?
This is a discussion thread to gather all ideas to be found about the question how to use Crank.js or Crank.js patterns to implement custom elements.

This is a brainstorming, nobody expects a fully sophisticated proposal. So please share every idea that comes to your mind: Requirements, API suggestions, best practices , dos and don'ts, known pitfalls etc.

Here's a list of some popular web component libraries for inspiration:

@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented Apr 22, 2020

IMHO it makes sense to provide (if somehow possible) meta information about the supported properties, attributes and events and also about the type of the properties. This makes for example auto-converting of (string) attributes to properties and vice versa much easier.
Moreover, explicit default property value declaration may help that reading element properties or attributes (like someElement.something / someElement.getAttribute('something')) will work out of the box (unfortunately, when using declarative, explicit default prop values then there's a good change that you'll get some subtle problems in TS typing - at least with the current TS version ... this will hopefully change in future).

In case that it is not really clear what I mean please find here simple demos where these features are used (FYI: These demos use a completely unimportant "just for fun" pre-alpha toy library, these demos are only meant as an example for a function based web component library with the above mentioned features):

[Edit: Updated demos]
https://codesandbox.io/s/tender-water-g56mc?file=/src/index.js
https://codesandbox.io/s/nameless-brook-hc5bf?file=/src/index.js
https://codesandbox.io/s/gracious-tereshkova-ftlyx?file=/src/index.js
https://codesandbox.io/s/wispy-darkness-tex5c?file=/src/index.js

@brainkim
Copy link
Member

brainkim commented Apr 25, 2020

Copy-pasting what I wrote in a reddit discussion https://www.reddit.com/r/javascript/comments/g1zj87/crankjs_an_alternative_to_reactjs_with_built_in/fnjwa5o?utm_source=share&utm_medium=web2x:

I actually think you can add public methods to function components in React using the “useImperativeHandle” hook, but I think the API is kinda hinky. I agree with you 100%, one good metric for a framework is if its components can be exported and embedded in other frameworks, and I think web components play a key role in providing a uniform, imperative interface. I was gonna provide a way to create web components with Crank but didn’t get the chance to figure out the API yet.

I sketched out what I wanted in my head: I want the whole props/attr stuff to be normalized, I want to reuse the generator pattern that Crank does for stateful components, and I want declarative JSX, not templates. But because you need to respond to each prop/attr individually, the API is gonna have to be a little different. I thought maybe something like this:

CrankWebComponent.register("my-video", function *(instance) {
  instance.play = () => {
    this.playing = true;
  }

  for (const [name, value] of this) {
    // some code which responds to each new property
    yield (
      <div>
        <video />
      </div>
    );
  }
});

As you can see, not fully fleshed out, but the idea is that you would just provide a generator function and Crank would create the WebComponent class for you and normalize the props/attr changes?

I dunno, I think web components get a bad rap, especially cuz it’s 4 separate technologies and most people haven’t even tried using them, and I’m really excited to try experimenting with them.

I really like the idea of a web component interface, and think it could be really important for solving the problem that React’s useImperativeHandle/class refs solves. I think whatever web component library we create should be:

  1. generator-based
  2. synchronous
  3. JSX not templates

The one thing is that we can’t really iterate over this and get props, because web components are mutated using individual props, and you have to respond to each prop update and set other props based on each individual prop update. It would be nice to have a conventional way to deal with attr/props mismatches too.

Lots of room to explore. I think this is a really important feature and I’m curious to hear what people’s thoughts are.

@brainkim brainkim added enhancement New feature or request help wanted Extra attention is needed labels Apr 28, 2020
@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented May 4, 2020

[Important: We should concentrate first on enhancing the general Crank design patterns ... "web components" should really not have a high priority at the moment... 😉]

I am very strongly of the opinion that for inspiration it's always good to see code examples of how others are trying to solve the problems you are currently trying to solve (independent whether you are a big fan of those solutions or not). So please allow me to show you another example using this web component toy library I have already mentiond above.
Hope you say to yourself "Mmmh, okay ... I see ... but I can do better ... hold my beer ..." ;-) and try to find better and in particular more Crankish solutions ...
This demo shows one possible answer to React's useImperativeHandle (be aware that that little c thingy is basically something like Cranks this/Context, also please be aware that everything here works completely (!) different than with React - don't be confused). [Off-topic: In near future I will - again for inspiration - show a similar little demo that will show a way to handle slots (aka. children 'n stuff) and also CSS with custom element's that use shadow DOM (it is a bit more challenging than it might sound) ... to be continued :-)]

component('simple-counter', {
  props: {
    label: prop.str.opt('Counter'),
    initialCount: prop.num.opt(0)
  },

  methods: ['reset'] 
}, (c, props) => {
  const
    [state, setState] = useState(c, { count: props.initialCount }),
    onIncrement = () => setState('count', it => it + 1)

  useMethods(c, {
    reset(count = 0) {
      setState({ count })
    }
  })

  return () => html`
    <button @click=${onIncrement}>{props.label} {state.count}</button>
   `
})

Please find a running demo here:
https://codesandbox.io/s/dazzling-spence-s1zme?file=/src/index.js

@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented May 5, 2020

Again, for inspiration, here's a litte demo of a custom element that uses it's own (dedicated) CSS styles and has two different slots. Again, the goal is to find a better and more Crankish solution than this.
For those who are not yet familiar with web components: Custom elements can (but they do not have to!) use a so called "Shadow DOM" which isolated the CSS classes of the document from the CSS classes dedicated for the custom element. If you want to use CSS classes inside of the custom element's "Shadow DOM" you have to add the corresponding style element to the component's shadow DOM itself (for example if the document uses Bootstrap then "Shadow DOM" custom elements cannot use those Bootstrap CSS classes of the document - you'll have to load the Bootstrap styles also inside of the custom element to use them).
Also it's important to know that if your custom element uses slots it always has to use "Shadow DOM".

Please find here an example implementation using this web component toy library I have already used for the examples above.
The important part is the implementation of component InfoBox aka <info-box ...> especially all occurrences of the word "slot".

My previous demos used lit-html, which is a great library, but unfortunately I personally prefer to implement in TypeScript and lit-html does not allow the same level of type safety as you are used with React and TSX. So this time I've tried to show a way how it's possible (in theory) to be fully typesafe using JSX (by using <InfoBox...> instead of <info-box> ... and as a little gimmick that InfoBox function does out-of-the-box also allow a non-JSX way to build virtual DOM trees (in case you want to implement in pure ECMAScript) => see "demo-2".

BTW: That toy library is still buggy as hell :-(... don't expect that anything else is working beyond these little demos:

https://codesandbox.io/s/js-elements-demo-uxkfu?file=/src/index.js

@brainkim
Copy link
Member

brainkim commented May 5, 2020

@mcjazzyfunky Hmmm definitely not a cranky API but interesting and impressive.

An interesting conversation on web components: https://twitter.com/RyanCarniato/status/1257806356947464193

@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented May 6, 2020

The fact, that the stateful components in my demos are based on a complete different pattern than Crank is not important here (just replace the function argument with a crank component function and you have a more Cranky API). What I've tried to show is that Brian's example above

CrankWebComponent.register(tagName, crankyFunc)

  1. generator-based
  2. synchronous
  3. JSX not templates

could be extended to something like:

const MyComponent = registerCrankComponent(tagName, options, crankyFunc)

where the argument options allows to specify some meta data like the involved props, possible default props, prop types, method names, slot names etc.
If the register/registerCrankComponent function will return something (function or whatever - not a string) that can be used as first argument of the createElement function then the whole thing could be properly typeable in TS (<my-component ...> will normally not be type safe, but <MyComponent ...> will).

// [Edit]
// Or as an alternative maybe something like this,
// in case those web components are completely based
// on "Crank.js the library" and not only "Crank the design pattern":

const MyComponent = component(config, crankyFunc)

CrankWebComponent.register(tagName, MyComponent)

[Edit] A bit later, I doubt that this "alternative" is really working the way I wanted it to work. I think MyComponent must know the tagName so something like the first suggestion might be better.

@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented Aug 7, 2020

Hmmmh, I think I've changed my mind a bit here.
Above API suggestions were (more or less):

CrankWebComponent.register(tagName, crankyFunction)

After shortening the function name and adding an argument to provide component meta information (which and what props does the component have, which props are optional, which are required, and what are the default prop values?) you get:

defineCrankElement(tagName, meta, crankyFunction)

In the suggestions above crankyFunction was always meant to be either a "normal" function or an async function or a sync generator function or an asnc generator function.

My following proposal is different: Why not just ALWAYS use a pure, normal function for that third argument (nothing async and no generators). Just implement the whole complex component that shall be used as web component completely as a usual crank component function and then just wrap it directly as a web component, where I think a pure function will completely do the job.

Please find here a demo that hopefully shows what I mean: » Demo (in the demo I use a slightly different form - which I personally like better: defineCrankElement(tagName, { props?, slots?, render }). Custom components often need imperative methods, a topic which is not handled in the demo. I think a single second argument ref or setMethods for that render method should work.
[Edit] Updated demo to also show this setMethods functionality.

@brainkim
Copy link
Member

brainkim commented Aug 7, 2020

@mcjazzyfunky Interesting! I like the prop/attr normalization system you got going.

The big thing web components need is an imperative API; that’s what motivates their usage above and beyond just regular Crank components. For instance, if I do document.getElementsByTagName("crank-counter")[0] in your example, the element should have custom methods or properties which allow me to affect rendering. Like in the above example it would make sense for there to be an imperative reset method, which resets the counter to the initial value. And you should probably also be able to get/set the label of the counter.

@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented Aug 7, 2020

The big thing web components need is an imperative API

Like said, a second argument for that render method (which could be called ref or setMethods or whatever name is preferred) will do the job.
I've updated my demo above to show this setMethods stuff (basically the couterpart to React's useImperativeHandle) and some imperative component prop updating:
https://codesandbox.io/s/crank-webcomponent-demo-forked-0d0km?file=/src/index.js

A good thing about this defineCrankElement approach is that if you use a simple adapter pattern, then the
implementation of definePreactElement is only about 5 additional lines of code away.
Same for a defineDyoElement etc. (only a defineReactElement will be more complicated, as React is not very web component friendly)

PS: The demos do not show how to handle events but it will be just:

defineCrankElement('crank-counter', {
  props: {
    ...,
    onSomeEvent: prop.func.opt()
  },
  ...

... not necessarily very easy to implement, but doable.

@brainkim
Copy link
Member

brainkim commented Aug 7, 2020

@mcjazzyfunky Wow that’s getting there! One thought I have. The benefit of inheritance is that you can just define methods directly on the class, rather than as a callback inside the component, which feels a little mind-bending. It means that your Crank components have to be aware of your webcomponent logic, which feels off to me. I do like the idea that the web component class only takes a single pure function. That simplifies a lot of things, and I’m not sure why I wanted the web component API to use generator functions in the first place. What about something like this?

class MyComponent extends McJazzyFunkyComponent {
  constructor() {
    super({count: prop.num.req(), label: prop.str.opt('Count')}, (props) => (
      <Counter count={props.count} label={props.label} />
    ));
  }
  reset() {
    this.count = 0; // triggers internal connectedCallback logic
  }
}

You’re free to use whatever API you want of course, just brainstorming some API ideas.

@mcjazzyfunky
Copy link
Contributor Author

It means that your Crank components have to be aware of your webcomponent logic

Actually that was the idea (maybe not the best idea 😄): Write a common Crank component that has all properties and imperative methods that you want and then with a few lines of code wrap that Crank component 1:1 in a custom element class (even if you do not see the class in my demo - under the hood there is a custom element class of course). After that you have a Crank component and a custom element component that have both equal features.
If that does not make sense for let's say more sophisticated components, then I think the whole idea itself may not be really helpful.

In your latest examples you made the Counter component stateless and instead the web component stateful.
But then: Why does the the custom element as both a writable count property plus a reset method?

Anyway, as that topic is not really very urgent, I think it makes sense to wait for other API proposals and ideas and reevaluate again in some weeks.

@mcjazzyfunky
Copy link
Contributor Author

mcjazzyfunky commented Aug 10, 2020

Okay, maybe my last proposal will not fit all needs.
Please find here a modification of the demo where the configuration parameter main is basically a common Crank function (all four function types supported). The only difference is that the crank context will be passed as second argument and also there is a third argument setMethods (using this here would feel a bit odd IMHO, but that's just a not-so-important detail, I guess).

https://codesandbox.io/s/crank-webcomponent-demo-forked-uxi0m?file=/src/index.js

defineCrankElement('crank-counter', {
  props: {
    initialCount: prop.num.opt(0),
    label: prop.str.opt('Counter')
  },

  methods: ['reset'],

  *main(props, ctx, setMethods) {
    let count = props.initialCount

    const onIncrement = () => {
      ++count
      ctx.refresh()
    }

    setMethods({
      reset: (n = 0) => {
        count = n
        ctx.refresh()
      }
    })

    for (props of ctx) {
      yield (
        <button onclick={onIncrement}>
          {props.label}: {count}
        </button>
      )
    }
  }
})

I personally prefer this function based syntax.
But I guess most folks will prefer a class-based solution (I think this is more or less a matter of taste).
Unfortunately it will take some time till this decorator and field declaration features will be available in the ECMAScript standard.

// Abstract class CrankComponent does NOT extend anything
// (especially not HTMLElement).
// CrankComponent implements the Crank context interface. 
@component('crank-counter') // will register custom element
class Counter extends CrankComponent {
  @prop(Number)
  initialCount = 0
  
  @prop(String)
  label = 'Counter'
  
  @state() // with auto-refresh support
  count = 0
  
  @method()
  reset(n: number = 0) {
    this.count = n
  }
  
  *main() {
    this.count = this.initialCount
    const onIncrement = () => (++this.count)

    while (true) {
      yield (
        <button onclick={onIncrement}>
          {this.label}: {this.count}
        </button>
      )
    }
  }
} 

[Edit -hours later] Hmm, maybe I prefer this syntax to the one that I have implemented in the demo above (shortening defineCrankElement to defineElement and using this again).
Maybe that looks a bit more crank-esque.

https://codesandbox.io/s/crank-webcomponent-demo-forked-ydsbt?file=/src/index.js

const counterMeta = {
  name: 'crank-counter',

  props: {
    initialCount: prop.num.opt(0),
    label: prop.str.opt('Counter')
  },

  methods: ['reset']
}

defineElement(counterMeta, function* (props, setMethods) {
  let count = props.initialCount

  const onIncrement = () => {
    ++count
    this.refresh()
  }

  setMethods({
    reset: (n = 0) => {
      count = n
      this.refresh()
    }
  })

  for (props of this) {
    yield (
      <button onclick={onIncrement}>
        {props.label}: {count}
      </button>
    )
  }
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

2 participants