super-mithril-hyperscript
TypeScript icon, indicating that this package has built-in type declarations

0.0.0-next.1 • Public • Published

super-mithril-hyperscript

⛅ A hyperscript wrapper function that adds a lot of nice reactive functionality to mithril

What

The hyperscript extensions used by super-mithril. It handles all the complexity of making mithril's hyperscript more reactive.

It is not intended to be used directly, but is instead isolated to a small library so it can be aggressively tested within its small set of responsibilities.

But you could use it to make your own super-mithril that supports a different observable library, or has different vnode extensions.

Quick Start

This library is still pretty low level as its designed to be wrapped by something higher level, so the setup code isn't as terse as that higher level library would be. So here goes:

import * H from 'super-mithril-hyperscript'
import { Stream } from 'super-mithril-stream'
import M from 'mithril'

const trackCreated : H.Options["trackCreated"] =  function trackCreated(visitor, set){

    const streams = new Set<Stream>()
    const out = Stream.trackCreated(visitor, streams)

    for( let s of streams ) {
        set.add(() => s.end(true))
    }
    return out
}

const trackReferenced : H.Options["trackReferenced"] =  function trackReferenced(visitor, set){
    
    const streams = new Set<Stream>()
    const out = Stream.trackReferenced(visitor, streams)

    for( let s of streams ) {
        set.add(() => s.end(true))
    }
    return out
}

const m = H.setup(M, { 
    server: false,
    trackCreated,
    trackReferenced,
    createSubject: Stream,
    effecthandlers: [],
    decorateComponentVnode(v: H.VnodeInternal<any>){
        function useEffect<U>(
            options: { name?: string; seed: U },
            effect: FX_Visitor<U>
        ): Stream<U>;
        function useEffect<U>(
            options: { name: string },
            effect: FX_Visitor<U | undefined>
        ): Stream<U | undefined>
        function useEffect<U>(options: any, effect: any): Stream<U> {
            return v.useEffect(options, effect)
        }
    
        return {
            useStream: Stream,
            useEffect,
        }
    }
})

const useInterval = (v: H.VnodeInternal) => {
    return v.useEffect({ name: 'useInterval' }, function * (ctx, self){
        let delay = (yield delay$) as number

        yield ctx.sleep(delay)

        return yield self + 1
    })
}

const App = m.component((v) => {
    let delay$ = v.useStream(100)

    let interval$ = useInterval(delay$)

    return () => m('.app'
        , m('The interval has emitted', interval$, 'times')
        , m('label'
            ,'delay (ms)'
            ,m('input[type=range]', {
                $onintut: { value: $delay }
            })
        )
        , () => () => () => m('p', 'This code will update without any global redraws')
    )
})

m.mount(document.body, App)

Extensions

Generic stream API

This library uses an adapatation of the sin observable API:

interface Observable<T = any> {
	observe(update: (x: T) => void): () => void
	['sin/observe']?: (update: (x: T) => void) => () => void
}

An object with an observe method that can write to an update function and returns an unsubscribe function. You can however inject a more complete Stream API via decorateComponentVnode. This is how super-mithril adds built in support for super-mithril-stream.

Automatic dependency tracking

Any Observable that is created in a component, a view, a thunk or an effect will be automatically cleaned up when that component, view, thunk or effect ends / unmounts.

v.useCleanup

You can hook into any tracking context being restarted / ended via v.useCleanup

const App = m.component((v) => {

    v.useCleanup(() => console.log('component unmounted'))

    v.useEffect({ name: 'example' }, function * (){
        v.useCleanup(() => console.log('component unmounted or effect restarted'))
    })

    return () => {
        v.useCleanup(() => console.log('view re-rendered or component unmounted'))

        return m('.example'
            , () => {
                v.useCleanup(() => console.log('component or thunk unmounted'))

                return m('h1', 'example')
            }
        )
    }
})

v.useEffect

Components get a useEffect function. This makes use of super-effects.

In short, you can yield observables to depend / unwrap them, if a dependency emits the effect restarts.

Whatever value you return will be accessible outside the effect as an observable.

let sum$ = v.useEffect({ name: 'example' }, function * (){
    let a = yield a$
    let [b,c] = yield [b$, c$]

    return a + b + c
})

You can also yield Promise's and the resolved/rejected value is injected into the effect.

v.useEffect({ name: 'example' }, function * (){
    try {
        yield m.request(....)
    } catch (e) {
        console.error('uh oh', e)
    }
})

yielded values are typed as any for now because typescript cannot currently accurately infer the types of yielded values except in the most basic way. But there are few open issues that give me hope that that will change in the future.

Any streams you create within an effect will be disposed automatically when the effect ends or restarts.

Naming effects is mandatory in anticipation of dev tools that will show you running effects.

There is also a context that is injected into the effect, this is documented in more detail here

Reactive Children

You can drop an Observable in the view as a child of any hyperscript. This observable will create its own isolated rendering context.

Note, if there is more than one sibling child the rendering context will be wrapped in a <stream-child> element to ensure m.render(childNode, ...) doesn't overwrite other siblings subtrees.

<stream-child> uses display: contents but this may still break your logic in css selectors that make assumptions about direct descendants.

const seconds$ = {
    observe(update){
        let x = 0;
        let id = setInterval(() => {
            update(++x)
        }, 1000)

        return () => clearInterval(id)
    }
}

// After 5 seconds:
// <h1><stream-child>5</stream-child>seconds</h1>
m('h1', seconds$, ' seconds')

Reactive Attrs / Style

You can also use streams for attributes and style properties.

m('h1', { style: { color: color$ }, disabled: disabled$ })

This uses the exact same algorithm mithril uses for setAttr and removeAttr so a false attribute will remove the attribute and a true attribute will add the attribute with a value of ''.

Thunk Attrs / Children

Thunks allow us to tell mithril to only run a piece of hyperscript one time. It is a great way to run some kind of side effect or setup code. You can use thunks as children and as attributes.

m('h1', { style: { height: () => height$.map( x => x + 'px' )} }, () => {

    // do anything you want here
    // ...

    return () => 'thunked'
})

Thunks can also be effects. If you use a coroutine thunk for an attr or child, the function will only run once but the subtree will be updated whenever the effect emits resolves.

m('pre', function * () {

    const res = yield m.request(...)

    return JSON.stringify(res)
})

Or

m('h1', {
    *color() {
        let { colour } = yield m.request('/api/theme')
        return colour
    }
})

Any effect thunks adds a corresponding promise to the vnode.promises list. This allows a possible future SSR implementation to optionally wait for any promises to resolve before resolving to HTML.

Note effect thunks also count as tracking contexts just like a normal useEffect so any streams created in an effect will automatically be disposed on restart/unmount.

Subscribe to streams via hyperscript

Sometimes you need to subscribe to 1 or more streams and slightly adjust their output for rendering. Instead of relying on something like stream.merge or stream.map you can just use hyperscript:

m(a$, b$, (a, b) => {


    return m('h1', 'a + b =', a + b)
})

This just returns a new Observable which is natively supported in the vtree. But you can also use this form anywhere in your model layer too.

Note: this works a lot like mithril's m.stream.merge, we wait for all observables to emit at least 1 value before emitting the merged stream.

Better Components

We support the existing mithril component API, but you also can just return a view in a closure component. We provide a no-op function m.component to help with type inference. It is recommended to use this for component definitions when using typescript but is completely optional.

m.component<{ name: string }>(() => {

    // v is type aware
    return (v) => m('h1', 'Hello', v.attrs.name)
})

We also provide a v.useEffect and v.useCleanup hook. Hooks are unlike react, there's no rules of hooks, use them in conditionals or loops, its all good.

m.component({ name: string }, (v) => {
    v.useEffect({ ... }, function * (){ ... })

    v.useCleanup(() => ...)

    return () => ...
})

Reactive Event Handlers (Dollar Functions)

Options

Because this is a low level middleware, all options are mandatory. It keeps the internals simpler if we assume tracking and decoration is always explicitly set.

server

Whether or not we are running on the server. Server usage is currently limited to static rendering with no hydration. In server mode we automatically use mithril-node-render's waitFor API for any promises. If you do not want to wait for promises to settle before rendering you can use the synchronous render option in mithril-node-render.

In future we'd like to tackle hydration but we'd likely need to inline the brower and node renderer's and make changes there which is out of scope here.

createSubject

Tells the library how to create subject observables. Used by useEffect to create its self stream. While the surface API supports the raw Observable API, the framework needs a way to push new values into the created stream, hence createSubject.

effectHandlers

Lets you customize how values are handled when yielded in effects. See here for more details

plugins

Hyperscript plugins that let you change how hyperscript handles arguments and the created vnode.

type Plugin = {
	before(args: any[], options?: Options): any[],
	after(vnode: Vnode, options?: Options): Vnode
}

Used to add built in support for super-mithril-css in super-mithril.

trackCreated / trackReferenced

Used to capture anytime an observable is created. This allows the library to automatically clean up dependencies when a tracking context ends.

This requires use to make sure that when observables are created/reference they write to the tracking set passing in to trackCreated/trackReferenced.

let trackingSets = Set<() => void>[]
function createSubject<T>(x: T): Observable<T>{
    //... create your observable

    trackingSets.at(-1)?.add(unsubscribe)
}

function trackCreated<T>(visitor: () => T, trackingSet: new Set<() => void>): T {

    trackingSets.push(trackingSet)
    let out = visitor()
    trackingSets.pop(trackingSet)
    return out
}

super-mithril-stream and zed both have built in trackCreated and trackReferenced functions, but if you are using some other observable library you might need to rig this up yourself as above.

trackReferenced is currently not used but included for future extension. We theoretically could automatically make any stream referenced in a tracking context a dependency. But currently we make that explicit. We may remove trackReferenced in the future.

decorateComponentVnode

You can extend the vnode api via the decorateComponentVnode function. This allows you to add more utilities, built in hooks, and more specific types to the vnode api. This is used by super-mithril to add useStream and to make useEffect specifically return a super-mithril-stream type.

This function just returns an object of mixins, the types of Vnode in this library always has a parameter to capture any extensions returned from this function.

FAQ

Why use generators instead of async functions?

  • Better dependency tracking
  • Can make custom observables as "first class" as promises
  • They are incredible

Isn't doing all this stuff in the hyperscript layer inefficient?

Not as much as you would think. We own the hyperscript process entirely, we do not dispatch to our hyperscript and then mithril's again. Within that hyperscript call we run more checks than native mithril but that is offset by the fact we do not need to render as often as mithril, more of the vtree is lazily evalutated.

Readme

Keywords

none

Package Sidebar

Install

npm i super-mithril-hyperscript

Weekly Downloads

0

Version

0.0.0-next.1

License

MIT

Unpacked Size

217 kB

Total Files

8

Last publish

Collaborators

  • jaforbes