⛅ A hyperscript wrapper function that adds a lot of nice reactive functionality to mithril
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.
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)
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
.
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.
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')
}
)
}
})
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)
}
})
yield
ed 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
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')
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 ''
.
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.
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.
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 () => ...
})
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.
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.
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
.
Lets you customize how values are handled when yielded in effects. See here for more details
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
.
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.
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.
- Better dependency tracking
- Can make custom observables as "first class" as promises
- They are incredible
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.