TodoMVC with Mithril

This page walks through the TodoMVC variant written with Mithril, a JavaScript library that knows nothing of Amber. It shows the same model as TodoMVC with Silk. The view is one Mithril component, written in Amber, and Mithril’s router puts it on the page. The variant is the guide to a plain npm library in an Amber project, as the libraries page starts it. This page shows what differs from the Silk variant, and what crosses the boundary between the two languages.

Taking Mithril in

Mithril is on npm. Install it, and tell the loader where its script is:

npm install mithril --save
{
  "paths": {
    "mithril": "mithril"
  },
  "shim": {
    "mithril": {
      "exports": "m"
    }
  }
}

This is mithril.amd.json, in the root of the project. Mithril’s mithril.js is no module. In a browser it sets window.m, so the shim says that the module exports m. grunt devel puts both into config.js. The package then imports the module, bound to a name:

Package
	named: 'TodoMVC-Mithril'
	imports: {'m' -> 'mithril'. 'amber-todomvc/TodoMVC-Model'}
	transport: (AmdPackageTransport namespace: 'amber-todomvc')

The key of the association is the name, and the value is the module. m is Mithril’s own name for itself, so the code reads as Mithril code does. The test runner under Node.js loads no browser script. So Gruntfile.js gives it an empty mithril module, as it gives it an empty jquery. grunt deploy:mithril puts mithril.js into the bundle, which is 459 KB.

The page

mithril.html has an empty .todoapp section:

<section class="todoapp"></section>

The view renders the whole markup of the template into it. So the page holds none of it, and nothing has to be hidden before Amber starts. The rest of the page is as silk.html, with MithrilTodoApp._start().

The view

The view is the package TodoMVC-Mithril, with one class, MithrilTodoApp. It keeps the todos under todos-amber-mithril. It has three slots. todoList is the TodoList, root the element it is mounted on, and editing the todo being edited, or nil.

Virtual nodes

Mithril builds the page from virtual nodes, and m makes one: m("li", attrs, children). m is a function, so in Amber it is a block, and value:value:value: calls it. The selector carries the tag, its classes, its id and its type. The attributes are a #{ } literal, which is a plain JavaScript object, and a handler is a block:

main
	^ m value: 'section.main' value: #{ 'hidden' -> todoList isEmpty } value: {
		m value: 'input#toggle-all.toggle-all[type=checkbox]' value: #{
			'checked' -> todoList allAreCompleted.
			'onchange' -> [ :event | self markAll: event target checked ] }.
		m value: 'label[for=toggle-all]' value: 'Mark all as complete'.
		m value: 'ul.todo-list' value: (todoList shownTodos collect: [ :each | self item: each ]) }

view answers an array of three such nodes, the header, the main section and the footer. The header holds the heading and the input, with onkeydown bound to addOnEnter:. The footer holds the counter, the three links and the button. The hiding is an attribute, hidden, from the model’s answer. Mithril compares the new nodes with the old ones and changes the elements that differ.

The links come from the model. TodoFilter filters answers the three filters, and each has a route and a name:

filterLink: aTodoFilter
	^ m value: 'li' value: (m value: 'a' value: #{
		'href' -> aTodoFilter route.
		'class' -> (aTodoFilter == todoList filter ifTrue: [ 'selected' ]) asJavaScriptObject } value: aTodoFilter name)

The class is selected or nothing. The nothing goes as nil asJavaScriptObject, for a reason the section on the boundary gives.

Mounting

A component is an object with a view function. Mithril mounts it on an element, and a test does that:

component
	^ #{ 'view' -> [ self view ] }
attachTo: anElement
	root := anElement.
	self mithril mount: anElement value: self component

mithril answers JSObjectProxy on: m. A block understands Amber’s protocol alone. So the properties of m, such as m.mount, are reached through a proxy, as the JavaScript page says. mount:value: calls m.mount with the two arguments. The page mounts through the router instead:

routeIn: anElement
	root := anElement.
	(self mithril at: 'route') prefix: '#'.
	self mithril route: anElement value: '/' value: #{ '/' -> self routeResolver. '/:filter' -> self routeResolver }
routeResolver
	^ #{
		'onmatch' -> [ :params :path | self routeTo: path ].
		'render' -> [ self view ] }

m.route takes the element, the default route and the routes. Each route has a resolver. Its onmatch gets the path, /active say, and routeTo: sets the filter from it, through TodoFilter fromRoute:. Its render answers the view. The prefix is #. So the links of the template, #/active and the others, are the routes, and a reload keeps the filter. Mithril listens to the URL itself.

m.route is a function with properties of its own, prefix among them. A unary message to a proxy calls a property that is a function, so self mithril route would call m.route(). at: reads the property without calling it. What it answers is a function, and in Amber a block, which does not understand prefix:. So the class opts m.route and m.redraw out of Smalltalk once, when the package loads:

initialize
	| mithril |
	PlatformDom isFeasible ifTrue: [
		mithril := JSObjectProxy on: m.
		Smalltalk
			optOut: (mithril at: 'route');
			optOut: (mithril at: 'redraw') ]

From then on the two take messages as objects do, and prefix: sets m.route.prefix. Advanced JavaScript interop says what optOut: does. m itself stays a block, called for every node. The guard keeps the package loading under Node.js, where the test runner has no Mithril. The package imports amber/core/Platform-DOM for it.

Redrawing at once

Mithril redraws after a handler it bound, on the next animation frame. The view redraws at once instead, after every change:

toggle: aTodo
	todoList toggle: aTodo.
	self redraw
redraw
	(self mithril at: 'redraw') sync

So a test reads the page right after an event, as it does for the other variants. The redraw that Mithril schedules then finds nothing to change.

One todo

item: builds the list item of the template. The todo’s id is its key, so that Mithril keeps the element of each todo as the list changes:

item: aTodo
	^ m value: 'li' value: #{ 'key' -> aTodo id. 'class' -> (self itemClass: aTodo) asJavaScriptObject } value: {
		m value: 'div.view' value: {
			m value: 'input.toggle[type=checkbox]' value: #{
				'checked' -> aTodo isCompleted.
				'onchange' -> [ self toggle: aTodo ] }.
			m value: 'label' value: #{ 'ondblclick' -> [ self startEditing: aTodo ] } value: aTodo title.
			m value: 'button.destroy' value: #{ 'onclick' -> [ self remove: aTodo ] } }.
		m value: 'input.edit' value: #{
			'onupdate' -> [ :vnode | self focusEdit: vnode dom of: aTodo ].
			'onblur' -> [ :event | editing == aTodo ifTrue: [ self finishEditing: aTodo with: event target value ] ].
			'onkeydown' -> [ :event |
				event key = 'Enter' ifTrue: [ self finishEditing: aTodo with: event target value ].
				event key = 'Escape' ifTrue: [ self stopEditing ] ] } }

itemClass: answers completed, editing, both, or nil. The bindings do what those of SilkTodoItem do, and each ends in a redraw.

Editing

The item being edited is state of the view, in editing. startEditing: sets it and redraws, and the item gets the class editing. The input then needs the title and the focus. onupdate is a lifecycle method of Mithril: it runs after each redraw, with the node, whose dom is the element:

focusEdit: anInput of: aTodo
	editing == aTodo ifFalse: [ ^ self ].
	anInput = document activeElement ifTrue: [ ^ self ].
	anInput value: aTodo title.
	anInput focus

The input of the todo being edited gets the title and the focus once. It keeps what is typed through later redraws. finishEditing:with: clears editing, retitles and redraws. stopEditing clears it and redraws, for Escape. A blur saves only while editing is this todo. The redraw after Enter takes the class off the item, and the stylesheet hides the input. The blur that follows finds editing nil.

What crosses the boundary

Four things went from Amber to Mithril, and each has a rule:

What came back from Mithril needed nothing. The event in a handler and the node in onupdate are JavaScript objects, which answer their properties as messages.

What each point costs

The table of TodoMVC with Silk, for this variant:

The specification says Where it is What it takes
Hide main and footer with no todos main, footer one hidden attribute each
Focus the input on load header one oncreate
Enter adds, trimmed, empty ignored header, addOnEnter:, TodoList >> add: one handler; the model trims
Mark all as complete main, markAll: one checked attribute, one handler
Mark all cleared after Clear completed main, TodoList >> allAreCompleted nothing; the model answers false for no todos
Toggle, class completed item:, itemClass:, toggle: one handler, one class
Destroy button on hover item:, remove: one handler; the stylesheet shows the button
Double-click edits item:, startEditing:, focusEdit:of: one handler, one lifecycle method
Enter or blur saves, Escape cancels, empty destroys item:, finishEditing:with:, stopEditing, TodoList >> retitle:to: two handlers, one guard; the model removes the todo
The counter, item or items footer, TodoList >> activeCountLabel two nodes; the model words it
Clear completed, hidden when none footer, clearCompleted one hidden attribute, one handler
Persistence under todos-amber-mithril start, TodoStore one key; the model saves
Routing, class selected, kept on reload routeIn:, routeResolver, routeTo:, filterLink: Mithril’s router, one fromRoute:

The view has no rendering methods and no bindings, in the sense of the other variants. It answers the page from the model, and Mithril does the rest. The cost moved to the boundary. It is three asJavaScriptObject, value:value:value: on every node, a proxy on m, and two functions opted out.

The tests of the view

MithrilTodoAppTest, in TodoMVC-Mithril-Tests, answers attach:to: with a MithrilTodoApp mounted on the element, without the router. Mithril keeps every mounted element, so the test unmounts the app in tearDown, with detach. The 25 tests of TodoAppTest are the same as for the other variants. They drive the elements that Mithril rendered into the fixture. They read the page right after each event, since the view redraws at once.

Running it

Open http://localhost:4000/mithril.html, and try it as TodoMVC with Silk says. The todos are in localStorage under todos-amber-mithril. The test runner of Helios runs the 25 tests of MithrilTodoAppTest on the page.