Skip to content

Instantly share code, notes, and snippets.

@barneycarroll
Last active August 4, 2022 11:08
  • Star 11 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save barneycarroll/0e3c1b9811b47c012b13 to your computer and use it in GitHub Desktop.
Modulator: a light-touch API (with heavy internals) for auto-instantiating Mithril modules. Makes Mithril lifecycle management more user-friendly.
var mod = ( function initModulator(){
if( !Map ){
// A naive shim for maps functionality
var Map = shim;
var WeakMap = shim;
}
// Registry of instantiation contexts
var contexts = new WeakMap();
// All automated counts
var counts = new Map();
// Prevent infinite recursion if a modulated controller calls redraw
var pauseRedraw = ( function(){
var snapRedraw = m.redraw;
var redraw;
var forced;
for( var key in m.redraw ){
queueRedraw[ key ] = snapRedraw[ key ] = m.redraw[ key ];
}
return function pause(){
m.redraw = queueRedraw;
setTimeout( function unpause(){
m.redraw = snapRedraw;
if( redraw ) m.redraw( forced );
redraw = forced = false;
} );
}
function queueRedraw( force ){
redraw = true;
if( force ) forced = true;
}
}() );
var unique = {};
// Clear counts at the begninning of every redraw
m.module( document.createElement( 'x' ), {
view : counts.clear.bind( counts )
} );
// Shorthand for a component which will always return the same instance
mod.unique = function( component ){
return mod( component, unique, unique );
};
// Shorthand for a keyed component with a global context
mod.global = function( component, key ){
return arguments.length > 2
? mod( component, unique, key )
? mod( component, unique )
};
return mod;
function mod( component, context, key ){
var components = register( contexts, context || unique, WeakMap );
var keys = register( components, component, WeakMap );
return function identify( key ){
var count = key === undefined && register( counts, keys, m.prop.bind( undefined, 0 ) );
// eg. ctrl.mod( profile ).mapWith( users(), 'username' );
apply.mapWith = function( collection, keys ){
var keyed = typeof keys === 'array';
var path = [].slice.call( arguments, 1 );
return Object.keys( collection ).map( function getItemIdentifier( index ){
var key;
if( keyed ){
key = keys[ index ];
}
else if( path.length ){
key = path.reduce( function getKeyValue( source, segment ){
var node = source[ segment ];
if( node instanceof Function ) node = node.call( source );
return node;
}, collection[ index ] );
}
else {
key = index;
}
return identify( key )( collection[ index ], index, collection );
} );
};
return apply;
function apply(){
var args = [].slice.call( arguments );
var view;
if( count ){
key = count( count() + 1 );
}
var ctrl = register( keys, key, function newController(){
pauseRedraw();
var controller = component.controller || noop;
var instance = new ( controller.bind.apply( controller, [ controller ].concat( args ) ) )();
// Shorthand for instantiating sub-modules
instance.mod = function( component, key ){
return mod( component, instance, key );
};
// Force a re-instantiation of this controller on next redraw.
// Returns m.redraw to allow instant re-instantiation.
// So, to re-initialise with the same arguments and run a forced
// redraw immediately:
// ctrl.refresh( [].slice.call( arguments, 1 ) )()
instance.refresh = function(){
args = [].slice.call( arguments );
ctrl = register( keys, key, newController, true );
return m.redraw;
};
return instance;
} );
// Return the controller instance if the component is view-less.
if( component.view ){
if( args.length ){
view = component.view.apply( undefined, [ ctrl ].concat( args ) );
}
else {
view = component.view( ctrl );
}
if( view instanceof Object ){
view.ctrl = ctrl;
}
return view;
}
return ctrl;
}
}( key );
}
// Convenience map method: retrieve key from map. If it's not registered, set it first with Constructor.
function register( map, key, Constructor, force ){
return !force && map.has( key ) ? map.get( key ) : map.set( key, new Constructor() ).get( key );
}
function shim(){
var keys = [];
var values = [];
var map = {
get : function( key ){
var index = keys.indexOf( key );
return values[ index ];
},
has : function( key ){
var index = keys.indexOf( key );
return index > -1;
},
set : function( key, value ){
var index = map.has( key ) ? keys.indexOf( key ) : keys.length;
keys[ index ] = key;
values[ index ] = value;
return map;
},
clear : function(){
keys = [];
values = [];
},
delete : function( key ){
var index = keys.indexOf( key );
if( index > -1 ){
keys.splice( index, 1 );
values.splice( index, 1 );
return true;
}
return false;
}
};
return map;
}
function noop(){}
}() );
@barneycarroll
Copy link
Author

NB: For the sake of clarity, a component is a static object containing one or both of the controller and view properties; a module is an instance of such a component bound to the Mithril lifecycle.

Modulator

Modulator removes a lot of boilerplate by handling controller instance management automatically, allowing you to express your module invocation entirely in the view, in one expression, without the need for specifying higher order controllers – but without losing any of the inherent functionality of Mithril's controllers either. Thanks to the affordances for implicit controllers in 0.1.29, the componentization example in the Mithril guide becomes:

var dashboard = {
    view : function(){
        return [
            mod( userProfile )(),
            mod( projectList )()
        ];
    }
};

Of course, the example above is simplistic and ignores the fact that controller lifecycle management fulfils a purpose: idiomatic Mithril usage initialises controllers once when the module is first invoked, and then once for every route change. In theory this is a useful property for initialising components automatically. In practice, only top-level components (bound with m.route & m.module) get this for free: vanilla Mithril puts the onus on the developer to instantiate sub-component controllers manually (in their parent controller). Other componentization proposals either fail to cater for re-initialisation, or adopt the semantics of virtual DOM node identity, whereby position in the DOM tree or a unique string identifier are used to determine whether the element should be created from scratch – effectively making initialisation equivalent to a view's root node's config function, but taking place before or during the virtual DOM rendering loop.

Modulator aims to cater for any complexity of controller instantiation that would be possible with vanilla Mithril without forcing the burden of expressing that complexity on people who don't want it.

Signature

Modulator is a functor. It's first function accepts a component and optional arguments to allow differentiation for automatic state management, and returns a function that corresponds to a distinct module instance. This second function accepts arguments which are passed to the module's methods and return the view:

mod( component, context?, key? )( ...args? )

component

The component definition. If a function is passed, it is assumed to be a view. If no view is present, the controller instance will be returned. As with Mithril's m.module and m.route methods, modules returned by controller-less components will still get a free unique instance passed through as the first argument of the view. If a component is passed without a context or a `key, each module invocation in a view loop will return a new instance: subsequent renders will fetch the previously initialised instance based on execution order.

context

Context is used to isolate multiple component instances within their parent module instance, and should correspond to the module's ctrl instance. Why would you want to do this? Imagine you have a page with an auto-complete dropdown component that establishes data-binding hooks based on input data. Initialising it without a context argument means it will always return the same instance, because it's always the first registered dropdown component. By providing it with a unique context you can ensure it never returns the wrong instance. When you don't provide a context, modulator assumes all instances share the same global context.

key

When key isn't supplied, modulator starts a counter for each component + context, and increments it with each render. At the beginning of each redraw cycle, the counter is reset. Specifying a key instead of using the default rendering order index is useful if rendering order can vary within context. A lot of componentization APIs use keys exclusively, but this means you have to take care of unique identifiers throughout the application. In contrast, specifying a context and a key means you only have to worry about uniqueness within your current scope, which is trivial.

...args

After consuming all the component identification parameters, modulator returns a function that accepts any arguments you want to pass to the module. These are passed straight through to the controller and the view – although they will be offset by 1 in the view function to allow the view to receive a unique context (the controller instance, if the component provided one).

Extras

mod.extend

Modulator extends controllers with a couple of convenience methods. You can stop this happening by setting mod.extended = false.

ctrl.mod( component, key? )

A partially applied call to modulator with context pre-set to the current instance. Components defined controllers will still populate their module views with instances, so this syntax is convenient if you know the component will be 'modulated':

var formComponent = {
  view( ctrl, selections ){
    return selections.map( ctrl.mod( dropdownComponent ) );
  }
}

ctrl.refresh( ...args? )

Allows a module to refresh itself with the passed in arguments. 'Refreshing' means forcing the controller to re-initialise the next time the view renders. Even if the component doesn't have a controller of its own, this can be useful for forcing all sub-modules to be reinitialised in the next redraw. Controllers and views always receive all the arguments passed in in the first place, so passing in the same thing again is trivial:

var thing1 = {
  controller( arg1, arg2 ){
    // From the controller:
    this.reset = () => this.refresh( arg1, arg2 )
  }
};

var things2 = {
  view( ctrl, ...args ){
    return m( 'button', {
      // From the view:
      onclick : () => ctrl.refresh( arg1, arg2 );
    }, 'Reset!' );
  }
}

The refresh method returns m.redraw, so you can force an instant re-computation as follows: ctrl.refresh( ...arguments )().

Miscelaneous API hooks

mod.extend can be set to false if you prefer controllers not to be extended with mod and refresh methods.

mod.unique( component ) will initialize the module when it is first called and return the same instance thereafter. The only way to reinitialize the module is internally via ctrl.refresh.

mod.cleanup is true by default if modulator is running in an environment without support for native Javascript Maps & Weakmaps. Native Weakmaps are optimised for garbage collection. In cases where they are not present, and mod.cleanup is true, modulator will destroy module instance registries when their context unloads by safely binding to Mithril's onunload hook. The binding does not replace pre-assigned functions and takes account of preventDefault.

@barneycarroll
Copy link
Author

New API hooks:

mod( component, context? ).mapWith( collection, ...keyStrings? ) iterate over each item in object or array-like collection, using collectionItem[ keyString ] to provide unique keys. This is especially useful when iterating over a list whose order may change. You can supply multiple keyStrings for deep key identification and these can be methods (as long as methods don't expect arguments). For instance:

var users = [
  {
    names : [ 'Barney', 'Carroll' ],
    /* ... */
  },
  {
    names : [ 'Nikolai', 'Fyodorovich', 'Fyodorov' ],
    /* ... */
  }
];

mod( profile ).mapWith( users, 'names', 'toString' );

If no keyStrings are provided, it will use the key of the item under iteration. This can be useful for un-ordered objects, where keys will be unique.

mod( document.body, component )( ...args? ) brings Modulator closer in line with Mithril's component branch and allows 'turtles all the way down'. In combination with the default mod.extend = true, this means all ctrl instances will have a context-bound mod method for convenient sub-module invocation.

@barneycarroll
Copy link
Author

mapWith overload option

mapWith( collection, keysArray ) specify the keys to be used for each item.

@barneycarroll
Copy link
Author

Update

Modulator was created prior to Mithril 0.2. With the advent of m.component, Mithril 0.2 supposedly has its own core method for easy automatic component invocation and automatic management. Modulator provides a few things that m.components doesn't. Significantly:

  1. A clear separation between component instantiation logic and arguments to be passed to the component. Mithril handles this via a key property in the first argument passed to the component, meaning component API is compromised and liable to confusion of concerns.
  2. Component instantiation logic is used exclusively for determining the conditions in which to initialise or retrieve a controller, and doesn't affect anything else. Mithril conflates key with DOM identity, meaning redraw strategy is compromised.
  3. An infinitely greater degree of control over initialisation logic, allowing, for example:
    • Perpetual controllers that can persist through route changes
    • The ability to move component position anywhere in the DOM without reinitialising
  4. Mapping over collections as a first class API method

Then there are things that Mithril 0.2 does do, which Modulator doesn't:

  1. Blocking subcomponent view renders
  2. onunload method triggering for subcomponents

@barneycarroll
Copy link
Author

Update

Mithril's config API for real DOM access has similar limitations to 0.2 component invocation in that it mandates a 1-to-1 relationship between virtual and real DOM in order for lifecycle hooks to behave as expected.

This interface solves that problem.

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