MVCoconut / coconut.ui

Wow, such reactive view! Much awesome!
The Unlicense
89 stars 9 forks source link

Coconut UI Layer

Gitter

This library provides the means to create views for your data. It shares significant similarities with React. One of them is its API, which has increasingly converged with React's for higher familiarity, easier porting and better interoperability with react. Furthermore, just like React requires e.g. react-dom to render to the DOM, coconut also must be accompanied by a rendering backend:

Coconut views use HXX to describe their internal structure, which is primarily driven from their render method. This is what a view basically looks like:

class Stepper extends coconut.ui.View {

  @:attribute var step:Int = 1;
  @:attribute function onconfirm(value:Int);
  @:state var value:Int = 0;

  function render() '
    <div class="counter">
      <button onclick={value -= step}>-</button>
      <span>{value}</span>
      <button onclick={value += step}>+</button>
      <button onclick={onconfirm(value)}>OK</button>
    </div>
  ';
}

A function with just a string body is merely a syntactic shortcut for a function with return hxx('theString'). So if you want to be more explicit or do something else in your rendering function, you could write:

class Stepper extends coconut.ui.View {

  @:attribute var step:Int = 1;
  @:attribute function onconfirm(value:Int);
  @:state var value:Int = 0;

  function render() {
    trace("rendering!!!");
    return hxx('
      <div class="counter">
        <button onclick={value -= step}>-</button>
        <span>{value}</span>
        <button onclick={value += step}>+</button>
        <button onclick={onconfirm(value)}>OK</button>
      </div>
    ');
  }
}

The promise of coconut.ui is: whenever your data updates, your view will update also. This assumes that you do not defeat coconut's ability to observe changes.

Every view has a number of attributes and states, that we'll look at in detail below. If you have a passing familiarity with React, you can roughly think of the attributes being the props and the states being the state.

Attributes

Attributes represent the data that flows into your view from the outside (usually the model layer or a parent view) and callbacks that allow the view to report changes. The above Stepper example has one of each.

You define attributes in one of the two following ways:

The following things mean the same:

  //as used in the above Stepper:

  @:attribute var step:Int = 1;
  @:attribute function onconfirm(value:Int):Void;

  //is equivalent to:

  var attributes:{
    var step:Int = 1;
    var onconfirm:tink.core.Callback<Int>;
  };

  //is equivalent to:

  var attributes:StepperAttributes = { step: 1 };
  //where
  typedef StepperAttributes = {
    var step:Int;
    var onconfirm:tink.core.Callback<Int>;
  }

Controlled Attributes

Sometimes a view reads from and writes to a single property, such as an @:editable property of a model, or the @:state of a parent. One solution to this is to pass down the data, as well as a callback for when the view wants the property to change. A shorter, and semantically more explicit alternative is to define controlled attributes:

class Key extends View {
  @:attribute var value:Int;
  @:controlled var current:Int;
  function render() '
    <button class=${{ selected: value == current }} onclick=${current = value}>$value</button>
  ';
}

class KeyPad extends View {
  @:state var value:Int = 0;
  static var max = 10;
  function render() '
    <div>
      <for ${i in 0...max}>
        <Key value=$i current=${value} />
      </for>
    </div>
  ';
}

As you see Key::current is passed as an attribute in KeyPad, but Key can alter it, which will take effect on the parent's state.

Currently, controlled attributes cannot be passed down via spreads.

Children

Views may also consume children, which are handled very much like attributes in almost every way, except in how they're specified in HXX.

The following are all equivalent:

class Button extends View {
  @:attribute function onclick():Void;
  @:attribute var children:String;
  function render() '
    <button onclick={onclick}>{children}</button>
  ';
}

class Button extends View {
  @:attribute function onclick():Void;
  @:children var label:String;
  function render() '
    <button onclick={onclick}>{label}</button>
  ';
}

class Button extends View {
  @:attribute function onclick():Void;
  @:child var label:String;
  function render() '
    <button onclick={onclick}>{label}</button>
  ';
}

And you would use any of them like so:

<Button onclick={trace("World!")}>Hello</Button>

Implicit attributes

Implict attributes serve the same purpose as react context. Let's take a look first:

@:default(Theme.LIGHT)
class Theme {

  static public final LIGHT = new Theme('#333', '#f8f8f8');
  static public final DARK = new Theme('#eee', '#444');

  public final foreground:Color;
  public final background:Color;

  public function new(foreground, background) {
    this.foreground = foreground;
    this.background = background;
  }
}

class MyUi extends View {
  @:implicit var theme:Theme;
  function render() '
    <div style=${{ background: theme.background, color: theme.foreground }}>
      <button style=${{ background: theme.foreground, color: theme.background, border: 'none' }}
    </div>
  ';
}

By default, MyUi will render with the light theme, as determined by @:default(Theme.LIGHT). To achieve the opposite effect, you can do the following:

  1. @:implicit var theme:Theme = Theme.DARK; - this way, the default theme will be dark, no matter what's defined globally for Theme via @:default.
  2. <Implicit defaults={[ Theme => Theme.DARK ]}><MyUi /></Implicit> - this will use the dark theme as a default for all children of that <Implicit /> (much like a context provider in react)
  3. <MyUi theme=${Theme.DARK}> - this will explicitly make MyUi use the dark theme. Note that this will not affect the default for child views.

Precedence (decreasing):

There are a few restrictions in place:

  1. implicit values must be observable (or constant)
  2. implicit values must be instances or enum values. Anonymous objects are (currently) not supported.
  3. any @:implicit field requires a default to be declared either in place or via @:default on the type. Lack of a default leads to compiler failure. This is to statically ensure a value is available.
  4. implicit values must be defined for the exact type, i.e. you cannot have this:

    interface I {}
    @:default(new A())
    class A implements I {
     public function new() {}
    }
    class Foo extends View {
     @:implicit var i:I;// I has no default
    }

    This is not possible, because there could be

    @:default(new B())
    class B implements I {
     public function new() {}
    }

    In this case it's not possible to know which class should be the default for the interface. You can however define a @:default on I that uses any suitable implementor.

States

States are internal to your view. They allow you to hold data that is only relevant to the view itself, but is still observable from the framework's perspective. In the Stepper example, clicking on the - button will decrement value and this will in turn cause a rerender that is going to update the content of the span that shows the current value to the user.

Your views may also hold plain fields for whatever purpose. Note though that updates to those will generally not cause rerendering.

If for whatever reason you want to skip observability checks (to have a constant-like attribute), use @:skipCheck. It works on individual fields or whole types. Either:

Laziness, granular invalidation and batched rerendering

Unless the particular renderer diverges from the norm, the following can be said about how views update:

@:tracked states and attributes

On occasion, you may wish for a certain state or attribute to cause a rerender, regardless of whether or not it was accessed in the render function. The most common case is a revision counter that is bumped when some value that is not (directly) observable has changed (e.g. your view's size on screen). Regardless of the use case, if you mark a state/attribute as @:tracked then changes to it will cause invalidation.

You may also specify expressions as parameters, with _ taking the place of the attribute to track sub-expressions.

Example:

@:tracked(_.get('Paris').population)
@:attribute var cities:ObservableMap<String, City>;

Refs

Just like React, coconut supports refs to get access to the elements/views you're creating.

'
  <div ref=${div -> if (div != null) console.log(div.innerHTML)}>
    <Stepper ref=${stepper -> trace(stepper.step)} />
  </div>
'

It is advised to use methods rather than anonymous functions for performance reasons.

@:ref syntax

You may also define refs like so:

class Counter extends View {

  @:ref var button:ButtonElement;
  @:state var counter:Int = 0;

  function render() '
    <button ref=${button} onclick=${counter++}>${counter}</button>
  ';

  function viewDidUpdate() {
    trace(button);//Will log <button>1</button> the first time you click.
  }
}

It is in fact possible to pass in any valid left hand value for an assignment, although that will also cause the creation of an anonymous function, which you want to avoid. Using @:ref avoids this and also makes the reference read only and thus safer to rely on.

Life cycle callbacks

Coconut views may declare life cycle callbacks, which are modelled after those in React, adjusted for the naming differences:

What React calls component and props, Coconut calls views and attributes respectively, as those are more specific terms: the term component can mean anything and in ECMAScript terminology, the state of a React component is a property.

viewDidMount

function viewDidMount():Void;

This callback is invoked after the component is mounted into the DOM (or whatever the native view hierarchy might be). It corresponds to React's componentDidMount

shouldViewUpdate

function shouldViewUpdate():Bool;

This function is invoked to determine if a component should rerender. While it mostly corresponds to React's shouldComponentUpdate, in contrast to React, it not pass nextState and nextProps. Instead, state and attributes changes are always applied before this function is invoked.

Caveat: if this function returns false, the view will only invalidate if any of the states or attributes that this function depends on changes (or any @:tracked attributes or states change).

This function exists only for optimization purposes.

getDerivedStateFromAttributes

static function getDerivedStateFromAttributes(states:States, attributes:Attributes):Partial<States>;

This function is called right before rendering and is expected to return an object, that may define a new value for each state. It corresponds to React's getDerivedStateFromProps.

getSnapshotBeforeUpdate

function getSnapshotBeforeUpdate():Snapshot;

This function is called after render, before the resulting changes take effect. Note that Snapshot is not a particular data type. You may either be explicit about it, otherwise it will be inferred by the compiler. Corresponds to React's getSnapshotBeforeUpdate, but note that prevState and prevProps are not passed. If you need these, you will have to track them yourself.

viewDidUpdate

function viewDidUpdate(snapshot:Snapshot):Void;

This callback is invoked after the updates resulting from render take effect.

The function has 0 parameters if you don't declare getSnapshotBeforeUpdate and 1 if you do. If you don't declare the parameter, a parameter called snapshot is created implicitly. If you don't explictly define the type of the one parameter, it will implicitly be inferred to the return type of getSnapshotBeforeUpdate.

While viewDidupdate mostly corresponds to React's componentDidMount, prevState and prevProps are not passed. If you need these, you will have to track them yourself.

viewDidRender

function viewDidRender(firstTime:Bool):Void;

This callback is invoked every time after the results of render are applied to the physical UI (e.g. DOM), with the passed boolean being true for the first call and false for all subsequent calls. You can use this as a combination of viewDidMount and viewDidUpdate.

viewWillUnmount

function viewWillUnmount():Void;

This callback is invoked before the view is unmounted and corresponds to While viewDidupdate mostly corresponds to React's componentWillUnmount.

Consider using untilUnmounted/beforeUnmounting instead.

untilUnmounted or beforeUnmounting

function untilUnmounted(cb:Callback<Noise>):Void;
function beforeUnmounting(cb:Callback<Noise>):Void;

One possibility (idiomatic in React) for cleaning up a view is to store any allocated resources in instance fields and then access them in viewWillUnmount, e.g.:

class Example extends View {
  var map:MutationObserver;
  function viewDidMount() {
    observer = new MutationObserver(...);
    observer.connect(...);
  }
  function viewWillUnmount() {
    observer.disconnect();
    observer = null;
  }
}

An alternative is to use untilUnmounted/beforeUnmounting (which are fully equivalent and should be picked depending on what reads more naturally) which take a Callback<Noise> that is executed before unmounting. So for example the code above would be written like so:

class Example extends View {
  function viewDidMount() {
    var observer = new MutationObserver(...);
    observer.connect(...);
    beforeUnmounting(observer.disconnect);
  }
}

That's shorter and avoids having instance fields that clutter completion. Another way to write the same is:

class Example extends View {
  function viewDidMount()
    untilUnmounted(() -> {
      var observer = new MutationObserver(...);
      observer.connect(...);
      observer.disconnect;
    });
}

This is absolutely equivalent with the previous version. The latter name makes most sense when used a call that returns a CallbackLink from tink_core. Let's assume we define something like this:

class Observe {
  static function mutations(target:Element, cb:Callback<Element>):CallbackLink {
    //... set up mutation observer here
  }
}

The we can use it like so:

class Example extends View {
  @:ref var root:Element;//Need to populate this in `render` of course
  function viewDidMount()
    untilUnmounted(Observe.mutations(root, () -> {
      //do something
    }));
}

untilNextChange or beforeNextChange

These two are anologous to untilUnmounted/beforeUnmounting, except that they fire before unmounting and before rerendering. Use these if you need to setup behavior that is cleaned up any time the component changes. Let's consider this rather silly view, that may change it's underlying root element every time it rerenders:

class Example extends View {
  @:ref var root:Element;

  function render() '
    <if ${Math.random() > .5}>
      <button ref=${root} />
    <else>
      <textarea ref=${root} />
    </if>
  ';

  function viewDidMount()
    untilNextChange(Observe.mutations(root, () -> {
      //do something
    }));
}

afterUpdating

function afterUpdating(cb:Void->Void):Void;

If you wish to run a function after the next update, you can schedule it per afterUpdating.

Avoiding typos in life cycle callbacks

To avoid typos when declaring life cycle callbacks, coconut warns if it sees functions that have names similar to the supported callbacks. To make absolutely sure your callback is correctly named, you may add override which is de-facto certain to cause an error if you mistype the name.

Renderer API

Renderers expose the following API.

package coconut.ui;

class Renderer {
  /// Mounts a part of vdom into the dom
  static function mount(target:js.html.Node, vdom:RenderResult):Void;
  /// Gets the native view (DOM node) corresponding to a given View (consider using refs instead)
  static function getNative(view:View):Null<js.html.Node>;
  /// Forces the synchronous update of all currently invalidated views
  static function updateAll():Void;
}

The above Renderer.mount and Renderer.getNative are equivalent to ReactDOM.render and ReactDOM.findDOMNode.