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

Rewrite JavaScript component layer for Mithril 1.0 #872

Closed
tobyzerner opened this issue Mar 18, 2016 · 44 comments
Closed

Rewrite JavaScript component layer for Mithril 1.0 #872

tobyzerner opened this issue Mar 18, 2016 · 44 comments
Assignees

Comments

@tobyzerner
Copy link
Contributor

Part of #262.

The base Component class is an abstraction layer on top of Mithril's raw components which makes components a bit easier to work with, a bit more React-like, and is better for extensibility.

It's very generic, with no specific ties to Flarum, so it should be extracted into its own external package so it can be used in other projects and developed independently of Flarum.

Unfortunately, its API is also very unfamiliar. It's really an odd mix of Mithril and React. We have the view method from Mithril, config as a method instead of a vdom attribute, onunload from Mithril, this.props from React, this.$() from Ember, and some of our own inventions like init and initProps. This mixture is bad for onboarding new core/extension developers, because they'll have to learn something new/different even if they're already familiar with Mithril/React.

I propose that when we extract this package, we change the API so that it reflects React as closely as possible. (The React API is more flexible and better for extensibility than the Mithril API.) The package can be called tobscure/mithreact – a React-like API for Mithril components.

Here is an example of a simple component – first with the current API, and then the equivalent with my proposed Mithreact API:

Current

class MyComponent extends Component {
  // 1. Initialize props as they come in
  static initProps(props) {
    props.icon = props.icon || 'mail';
    props.className += ' bar';
  }

  init() {
    super.init();

    // 2. Set initial state
    this.email = m.prop('[email protected]');

    // 3. Determine whether the component should redraw by dirty-checking a value
    this.subtree = new SubtreeRetainer(this.email);
  }

  view() {
    // 3. Determine whether the component should redraw by dirty-checking a value
    return this.subtree.retain() || () =>
      <div className={this.props.className}>
        <i className={'fa fa-'+this.props.icon}/>
        // 4. Get and set component state
        <input type="text" value={this.email()} onchange={m.withAttr('value', this.email)}/>
      </div>;
  }

  config(isInitialized) {
    if (!isInitialized) {
      // 5. Initialize the DOM
      this.$('input').css('border-color', 'red');
    } else {
      // 6. Update the DOM
      this.$('input').css('border-color', 'blue');
    }
  }

  onunload(e) {
    // 7. Hook onto component dismount, and potentially prevent it
    if (this.email() !== '[email protected]') {
      e.preventDefault();
    }
  }
}

Mithreact

class Dropdown extends Component {
  // 1. Initialize props as they come in
  getDefaultProps() {
    return {icon: 'mail'};
  }

  componentWillReceiveProps(nextProps) {
    nextProps.className += ' bar';
  }

  // 2. Set initial state
  getInitialState() {
    return {email: '[email protected]'};
  }

  render() {
    return (
      <div className={this.props.className}>
        <i className={'fa fa-'+this.props.icon}/>
        // 4. Get and set component state
        <input type="text" value={this.state.email} onchange={this.handleEmailChange.bind(this)}/>
      </div>
    );
  }

  handleEmailChange(e) {
    this.setState({email: e.target.value});
  }

  componentDidMount() {
    // 5. Initialize the DOM
    this.$('input').css('border-color', 'red');
  }

  shouldComponentUpdate(nextProps, nextState) {
    // 3. Determine whether the component should redraw by dirty-checking a value
    return nextState.email !== this.state.email;
  }

  componentDidUpdate() {
    // 6. Update the DOM
    this.$('input').css('border-color', 'blue');
  }

  componentWillUnmount() {
    // 7. Hook onto component dismount, and potentially prevent it
    if (this.email() !== '[email protected]') {
      return false;
    }
  }
}

Advantages of Mithreact:

  • It's more or less the same as the React API, so people will be familiar with it
  • By dividing things up into more methods with less arguments, things are cleaner, and extensions will have an easier time monkey-patching
  • Component state is encapsulated so we can potentially get some performance gains for free

Disadvantages:

  • Lots of refactoring to do for core + existing extensions

Thoughts?

@tobyzerner
Copy link
Contributor Author

Btw if we go ahead with this, we would do it in an incremental non-breaking way. So we can convert one component at a time without the deadline of any particular beta release, and then deprecate the old way, and then remove it before stable.

@tobyzerner
Copy link
Contributor Author

Also we could ditch the React-like API idea and just make some refinements to the current API, moving as far as we can to the other end of the spectrum (familiarity with Mithril's API). I'll brainstorm for that possibility in the morning.

I guess the most important thing is to extract it into an external package and document it properly.

@tobyzerner
Copy link
Contributor Author

As mentioned in the previous comment, I wanted to brainstorm going the other way (familiarity with Mithril's API rather than React's). Today I did a bunch of experimentation and research, including looking into the APIs if other virtual-dom frameworks, and trying to deduce a bit about what Mithril 0.3 and 1.0 will be like. Here's what I've come up with:

class Counter extends Component {
  constructor() {
    // 2. Set initial state
    // Stick with the freedom of the Mithril approach here, and encourage
    // use of m.prop.
    this.count = m.prop(0);

    // 3. Determine whether the component should redraw by dirty-checking a value
    this.observe(this.count);
  }

  // Named `render` rather than `view` because the superclass needs to
  // do some stuff to the subclass vdom, but we want to keep the external
  // API consistent with Mithril
  render({className, icon}) {
    // 1. Initialize props as they come in
    // No need for a separate method, doing it in the view is fine
    icon = icon || 'mail';

    return (
      <div className={className}>
        <i className={'fa fa-'+icon} />
        // 4. Get and set component state
        <input type="text" value={this.email()} onchange={m.withAttr('value', this.email)} />
      </div>
    );
  }

  // Split up `config` so that subclasses and extensions can more easily
  // override/patch these individual hooks

  didCreate(context) {
    // 5. Initialize the DOM
  }

  didUpdate(context) {
    // 6. Update the DOM
  }

  didRemove(context) {
    // 8. Destroy the DOM
  }

  // Wrap the context.retain API to make it more accessible
  shouldPersistDOM() {
    return true;
  }

  // Wrap the onunload(e) API to make it a bit nicer, and extensible
  shouldPreventUnload() {
    // 7. Hook onto component dismount, and potentially prevent it
  }
}

This is a relatively minor change to what we have now, compared to the idea of going "full React". So it shouldn't be too hard to implement :)

I'll name the package tobscure/mithril-es6-components.

@franzliedke
Copy link
Contributor

Looking good. 👍

Just a quick thought: If we went for a fully React-like API, would that give us compatibility (i.e. using React components)? Not if I understand it correctly, right? I suppose the underlying VDOM implementations wouldn't be compatible...

Regarding the transition: We could first create and fully test the package, before starting to transition the core components (and then the extensions), and support both APIs for one or two beta releases. (Given that they just extend Mithril, they will be compatible anyway, right?)

Do you want to host this under your account or under Flarum's?

@tobyzerner tobyzerner changed the title Extract JavaScript component layer into external package, adopt React-like API Extract JavaScript component layer into external package, refine API Mar 19, 2016
@tobyzerner
Copy link
Contributor Author

If we went for a fully React-like API, would that give us compatibility (i.e. using React components)? Not if I understand it correctly, right? I suppose the underlying VDOM implementations wouldn't be compatible...

Correct. We're still able to use any other Mithril components though, no matter what API we choose to implement.

Regarding the transition: We could first create and fully test the package, before starting to transition the core components (and then the extensions), and support both APIs for one or two beta releases. (Given that they just extend Mithril, they will be compatible anyway, right?)

Sounds good.

Do you want to host this under your account or under Flarum's?

My account. I think we should keep the Flarum organisation for Flarum-specific stuff (extensions, Flarum helpers/utils, docs, etc.)

Also, I forgot to mention a little perk: more than likely, this should future-proof us against any breaking changes in Mithril 0.3/1.0 :)

@franzliedke
Copy link
Contributor

Also, I forgot to mention a little perk: more than likely, this should future-proof us against any breaking changes in Mithril 0.3/1.0 :)

How so? Because we abstract it?

@tobyzerner
Copy link
Contributor Author

Yep. So any breaking changes we can just update in our abstraction.

@tobyzerner
Copy link
Contributor Author

tobyzerner commented Mar 19, 2016

Some other JS API todos while I think of them (will create more issues later):

  • Remove attrs API from Post class. Subclasses can override render and add class names there instead
  • Deprecate utils/classList, use classnames instead
  • Fork es6-micro-loader to allow for direct monkey-patching of modules (so utils + helpers can be patched)
  • Standardise some component method names, like data vs. submitData
  • Make monkey-patching utils fail silently instead of crashing if some object/method doesn't exist
  • Think about the way extensions will interact with each other, expose their own APIs, monkey-patch
  • Refactor DiscussionControls etc. into functions rather than objects, remove Controls suffix from sub-methods
  • Make a flarum/lib namespace so that forum and admin apps can extend components/models with the same name
  • Replace app.request internals with fetch API instead of m.request [maybe]
  • Import app instead of using it as a global
  • Set app.current in mapRoutes, instead of in the Page component constructor. Rename to app.page.
  • Remove unused mixin util
  • Extract Translator into an external package

@tobyzerner
Copy link
Contributor Author

Probably won't need to write this library as it looks like Mithril 1.0 will be coming soon – and it includes everything we need out of the box!

@tobyzerner tobyzerner changed the title Extract JavaScript component layer into external package, refine API Rewrite JavaScript component layer for Mithril 1.0 May 26, 2016
@sijad
Copy link
Contributor

sijad commented Aug 21, 2016

inferno is another library to think about, I know it's lots of work but it seems to be more promising and faster and also smarter.

@jiayihu
Copy link

jiayihu commented Aug 29, 2016

I always wanted to contribute to this project but having Mithril instead of React makes it difficult for many of us who are more familiar with the latter and don't have the time to learn the former.
If you are planning to build a React-like API, why don't just completely use React and gradually move away from Mithril?

@dav-is
Copy link

dav-is commented Aug 30, 2016

Because react is slow. Mithril is extremely Fast and lightweight. That's why it was chosen. Inferno may be a good candidate.

@tobyzerner
Copy link
Contributor Author

We'll be moving to Mithril 1.0 at some point in the future, which has a better API so we don't need to try and imitate React's. Some of the improvements are inspired by Inferno, so I don't believe Inferno offers any significant advantages over it.

@jiayihu
Copy link

jiayihu commented Aug 30, 2016

@dav-is Do you have any test which shows that some part of Flarum offers a better user experience with Mithrill rather than React?

I know the benchmarks comparing Mithril (and other virtual dom libraries) vs React but in a real-world usage React is just very fast and surely fast enough for users if used properly. For instance Netflix is using it and they support devices 256 times slower than a common laptop. I think the difference between React and Inferno, Mithrill and others like preact is that React is battle tested and covers a lot of edge cases. It also has a very large community and tooling. It offers a much better developer experience and helps building solid products in my opinion.

I'm not saying that React is more suitable than Mithril for Flarum, @tobscure is the person who knows best the requirements of the project. I'm just curious about the reasons behind the choice, apart from being "faster" in benchmarks rendering 10k rows.

As I said before, you are loosing some contributors because of this choice and I would like to know what reasons make it a good compromise. I'm not here to criticize but to help and maybe learn.

@franzliedke
Copy link
Contributor

I think that migrating to React isn't anywhere in scope for the upcoming major release. ;)

@edittler
Copy link

React is not totally open source. See its patents.
More info in this article.

@jiayihu
Copy link

jiayihu commented Sep 11, 2016

@ezeperez26 Maybe you should read more carefully the patent.

The license granted hereunder will terminate, automatically and without notice,
if you (or any of your subsidiaries, corporate affiliates or agents) initiate
directly or indirectly, or take a direct financial interest in, any Patent
Assertion: (i) against Facebook or any of its subsidiaries or corporate
affiliates, (ii) against any party if such Patent Assertion arises in whole or
in part from any software, technology, product or service of Facebook or any of
its subsidiaries or corporate affiliates, or (iii) against any party relating
to the Software.

Notwithstanding the foregoing, if Facebook or any of its
subsidiaries or corporate affiliates files a lawsuit alleging patent
infringement against you in the first instance, and you respond by filing a
patent infringement counterclaim in that lawsuit against that party that is
unrelated to the Software, the license granted hereunder will not terminate
under section (i) of this paragraph due to such counterclaim.

As many Facebook interns have already confirmed, this applies only when you are initiating legal action against Facebook. I seriously doubt Flarum will ever legally issue Facebook for something.

The article title is just click-bait. You can read more about this topic from official voices here: facebook/react#7293

@franzliedke
Copy link
Contributor

For reference: This ticket can hopefully also be closed once we upgrade to Mithril 1.0.

@tobyzerner tobyzerner removed this from the 0.1.0 milestone Jul 22, 2017
@dead-claudia
Copy link

dead-claudia commented Jan 24, 2018

(Mithril core dev here)

Just chiming in to ask: what issues have you all had with migrating? In particular, is there anything that could be done on our end (Mithril core or related in this project of mine) that could help ease migration?

@tobyzerner
Copy link
Contributor Author

tobyzerner commented Feb 8, 2018

Hey @isiahmeadows! We haven't attempted migration yet. Thanks for the link - that should make it easier. I'll give it a try soon.

Is there an equivalent of {subtree: 'retain'} in Mithril 1.x?

@dead-claudia
Copy link

dead-claudia commented Feb 8, 2018 via email

@gctommy
Copy link

gctommy commented Mar 13, 2018

React is not totally open source. See its patents

@edittler – not the case anymore now that it's under MIT license.

you are loosing some contributors because of this choice

@jiayihu – who knows how popular Flarum would have been now had they went with React or Vue.js with their vast ecosystem and network effect. But it's their choice to stick with Mithril (and the occasional sprinkle of jQuery).

@franzliedke
Copy link
Contributor

@isiahmeadows Thanks for getting back! We discussed this in our latest developer meeting and decided we would wait for and then jump directly to Mithril v2.

dsevillamartin referenced this issue Nov 11, 2019
Made class list for post extensible by using a separate method.
@franzliedke franzliedke added this to the 0.1 milestone Dec 8, 2019
@dsevillamartin
Copy link
Member

@isiahmeadows Hello, not sure if you'll see this but I'll put it here nonetheless. I am currently in the process of upgrading Flarum's JS frontned to Mithril v2.x, and have run into some issues that I'm not sure how to resolve.
Apologies if I use terms such as vnode and state incorrectly - the terminology confuses me quite a bit.


If you could provide any insight and/or recommendations on how things should be connected, that'd be greatly appreciated. I've looked through new and issues, documentation and SO questions, but haven't been able to figure it out.

You can check out the latest code at https://github.com/flarum/core/tree/ds/frontend-framework-rewrite-mithril/js/src.

Thank you for your time.


m.mount used to return the component instance

This was easily solved by adding a setter in the component constructor

vnodes do not modify the original component

In Flarum we keep references to other components like in our DiscussionPage
https://github.com/flarum/core/blob/46e2e17c3c0853bd9b40437007f1cedc7bb8d285/js/src/forum/components/DiscussionPage.js#L197-L199
https://github.com/flarum/core/blob/46e2e17c3c0853bd9b40437007f1cedc7bb8d285/js/src/forum/components/DiscussionPage.js#L107-L109

We rely on having the latest instance of that element - in other words, the current Mithril state. With Mithril v2.x, as it clones the component interface and doesn't modify the original, I am unsure of how to proceed.
We need to be able to execute methods in PostStream that affect the currently rendered stream, but what we have saved is an instance that isn't connected to anything - the vnode's state is different than what we can modify. For example, this.stream.element https://github.com/flarum/core/blob/d3ec99cb37654571c6cb9fc3910663c02d54aa35/js/src/common/Component.ts#L28-L31 is null when it is defined in the vnode's state.

I tried returning a component that simply runs the current instance's views & lifecycle hooks

screenshot

but this causes the DOM to be removed & recreated on every Mithril redraw as the check for if the object is equal is always false ({} !== {})

@dead-claudia
Copy link

I plan to come back to this later with a more in-depth analysis, but Mithril v1 and v2 have the same component interface - the only major difference is that you can't do vnode.state = ....

Regarding oncreate and onupdate, vnode.dom should always be set to the latest reference - could you reduce that to a minimal repro and file an issue with the version you used? I'd like to investigate further.

Also, your inline component will always fail anyways, because you're creating a new component each time, and Mithril diffs that via a simple vnode.tag === old.tag check. Have you considered returning a fragment instead? (Lifecycle methods can also be passed as attributes, and you can set those on fragments via m.fragment(attrs, ...children).)

@dsevillamartin
Copy link
Member

dsevillamartin commented Feb 29, 2020

@isiahmeadows Thank you for your response, I'll try what you suggested.

As for the vnode.dom thing, not sure if I explained myself correctly and/or am reading your response correctly. The issue is not that vnode.dom would be undefined, is that we are unable to access it from outside the component. Perhaps I need to try and explain this again, my apologies for that.

The main point is that we want to modify the component state by using its methods from outside the component. Right now, the saved component instance in this.stream (for example) does not modify the rendered DOM because it is not the actual rendered instance, as it is cloned before rendering.


EDIT: When I said that this.stream.element is null, this.stream is the instance created by new PostStream() and NOT the output of vnode.state. Perhaps I didn't explain that correctly.

Also, your inline component will always fail anyways, because you're creating a new component each time, and Mithril diffs that via a simple vnode.tag === old.tag check.

Yeah, that's why it didn't work as a solution. Not sure how m.fragment would help here as I still need to pass the children...? Though I seem to have kinda figured something out with it.

@dead-claudia
Copy link

@datitisev You can pass lifecycle hooks in m.fragment and elements both, via m.fragment({oncreate(vnode) { ... }, ...children), m("div", {oncreate(vnode) { ... }, ...children), and similar, which helps in a lot of more advanced use cases where you'd otherwise need to instate an intermediate component. You can still pass children as usual, just you can also pass attributes (including keys).

Regarding the stream issue, consider passing the component state instead, and consider not cloning the result, or at least avoiding cloning the element. If that's not possible for whatever reason (say, it's going through a worker boundary or similar), you might be able to get away with an ID pool not unlike what I did to coordinate arbitrary requests across an IPC channel, and just using an ID → value map where you set it at the sending end and read at the receiving end. You just need to be sure to release the ID once you no longer need it. (It's conceptually very similar to C's malloc/free, just with some indirection to the referenced memory, so it's not as simple as a pointer dereference to get.)

@dead-claudia
Copy link

We rely on having the latest instance of that element - in other words, the current Mithril state. With Mithril v2.x, as it clones the component interface and doesn't modify the original, I am unsure of how to proceed.

I decided to look a little deeper, and found something highly suspect. Right here: this is only a small variation of what's described in this anti-pattern in the docs.

You need to have components return their views directly, and as long as you use the component subclasses directly via m(SomeComp, ...)/<SomeComp ... />, you should be fine, and you can get rid of that render method entirely. This method is fine, since the this is the component class itself (and the component class will be invoked with new), but this method is not, since this is the component instance, and the this in each of the methods will be that subclass, not the initial instance.

For your PostStream specifically, if you want a surgical fix, try setting your scroll listener directly in oninit rather than in the constructor. That way, it gets created correctly without issue.

But in either case, you'll find it a lot easier to manage if you try to transition into a system where you're doing <ReplyPlaceholder{...props} /> rather than {ReplyPlaceholder.component({...props})}, as there's far fewer moving parts involved.

@dsevillamartin
Copy link
Member

@isiahmeadows Thank you very much for your thorough response. I'll see what I can do with the information you've given us here and try to implement some of your suggestions. I really appreciate it.

@dead-claudia
Copy link

Welcome!

@luceos
Copy link
Member

luceos commented Mar 6, 2020

@isiahmeadows Sorry to bother, I'm not the strongest technically speaking and I'm having a hard time understanding the meaning of this:

Regarding the stream issue, consider passing the component state instead, and consider not cloning the result, or at least avoiding cloning the element. If that's not possible for whatever reason (say, it's going through a worker boundary or similar), you might be able to get away with an ID pool not unlike what I did to coordinate arbitrary requests across an IPC channel, and just using an ID → value map where you set it at the sending end and read at the receiving end. You just need to be sure to release the ID once you no longer need it. (It's conceptually very similar to C's malloc/free, just with some indirection to the referenced memory, so it's not as simple as a pointer dereference to get.)

How would you pass the component state and what suggestion with using ID's do you mean? Could you potentially clarify this (with a snippet or linked to code)?

Thank you and sorry to be a pain, your help has been exceptional ❤️

@dead-claudia
Copy link

Where you would ordinarily pass the element (vnode.dom), pass the component instance (this === vnode.state) instead.

@dead-claudia
Copy link

And the IDs part, you can ignore that bit unless you genuinely can't pass the component instance anywhere, something usually only due to technical restrictions (like you're passing data through a web worker and back or similar).

In my case, I had to work around an OS-level process barrier where shared memory doesn't exist at all leaving cloning the only way in theory, so I had to do something much more complicated to work around it. The goal was to call a function and get a result, but that module of mine fundamentally has more in common with an HTTP implementation than, say, Relay or whatever. This was a particularly advanced need, though, one I've rarely encountered anywhere else.

@franzliedke
Copy link
Contributor

Haha, that's not going to apply for us, thankfully.

@isiahmeadows Thanks for the awesome support and for Mithril! ❤️

@dsevillamartin
Copy link
Member

dsevillamartin commented Mar 9, 2020

@isiahmeadows Not sure if I implemented it correctly. It kind-of works, the only problem is that the element does not redraw at all (onupdate and onbeforeupdate hooks aren't even fired) on the component (in this case PostStream) or its children. The DOM doesn't recreate now, which is good, but for some reason redrawing doesn't occur at all?

I've been debugging, trying to figure out what the root cause is, and it just seems to be my implementation of what you've suggested. The children redraw fine when outside of the PostStream, which means the issue is with how PostStream is rendered in DiscussionPage.

You can view the changes @ f39d0ab#diff-ccd768950e2518aaa4b440287d036a07.

And again, thank you for all your help so far.

@dead-claudia
Copy link

dead-claudia commented Mar 10, 2020 via email

@dsevillamartin
Copy link
Member

@isiahmeadows Oh I see, my apologies, O think I misread it. How would you recommend we store it? I can only think of adding it to an oncreate hook on the vnode attrs for the component as as far as I'm aware, the state is not set before then?

@dead-claudia
Copy link

dead-claudia commented Mar 10, 2020 via email

@dsevillamartin
Copy link
Member

Ah, I see. Thank you for all your time and help.

@askvortsov1
Copy link
Sponsor Member

Considering the changes we're making in mithril 2.0, how much of this is still relevant @datitisev?

IMO, with the new component lifecycle methods, the only part that might make sense to keep (although probably in an altered form) is:

getDefaultProps() {
    return {icon: 'mail'};
  }

  componentWillReceiveProps(nextProps) {
    nextProps.className += ' bar';
  }

  // 2. Set initial state
  getInitialState() {
    return {email: '[email protected]'};
  }

@dsevillamartin
Copy link
Member

@askvortsov1 Not sure.

@askvortsov1
Copy link
Sponsor Member

The still relevent bits of this have been extracted out; the rest is no longer relevant as there's no clear direction here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests