Cheers! This issue contains brainstorming and some ideas how the "future of DOM" would look like in my eyes. These are not direct feature requests, instead I'm hoping that these topics raise some discussion that (in the end) leads to decisions useful for the entire community.
I've already discussed about this in Cycle community but Cycle's ideology and these ideas seem contradictory, hence I'm continuing here in the case that Motorcycle community shares at least some of them. ๐
Transposition
Cycle had transposition (or "embedding observables into vdom") but it was removed lately. I'm not so familiar with the reasons but in my opinion that is one of the best features one can have in vdom, and proposing that it'll be included in @motorcycle/dom
because:
- It enables blazing fast DOM updates; when something changes in vdom, only the changed vnode can be diffed and patched in O(1) time
- It reduces boilerplate and redundancy because there is no need to combine observables before using them
- It's backwards compatible and opt-in; you can still use
O.combine
with it without having any negative performance / API usage impact
Consider BmiCalculator
from cycle-examples:
function view(bmi$, weightSliderDOM, heightSliderDOM) {
return xs.combine(bmi$, weightSliderDOM, heightSliderDOM)
.map(([bmi, weightVTree, heightVTree]) =>
div([
weightVTree,
heightVTree,
h2(`BMI is ${bmi}`)
])
);
}
Could be written as:
function view(bmi$, weightSliderDOM, heightSliderDOM) {
return div([
weightVTree,
heightVTree,
h2(bmi$.map(bmi => `BMI is ${bmi}`))
])
}
You may also notice an interesting extra feature: view
function doesn't need to know whether DOM params are observables or not. This makes e.g. unit testing easier and makes components more reusable; consider this:
function LabeledInput({title, value}) {
return h("label", [
title,
h("input", {type: "text", value})
])
}
Now the labeled input view doesn't need to know about the exact details of title
and value
, hence it can be called in many ways:
const inputA = LabeledInput({title: "Tsers", value: state.select("tsers")})
const inputB = LabeledInput({title: state.map(({foo}) => foo ? "Foo" : "Bar"), value: state.select("bar")})
const inputC = LabeledInput({title: "Const", value: "123"})
About type safety and "magic"
One might argue that embedded observables are not type safe or some "implicit magic". I don't see it that way - it just the contract of vnode. How about O.combine
(combine :: [Observable<A>] => Observable<[A]>
), is that any more magic? Nope, it's just a way how combine works.
When expressed with types, the signature of h
is something like this:
type Prop = string | num
type Child = string | VNode
type Children = Child | [Child]
type VNode = {value: Prop?, ..., children: Children}
h :: (string, {value: Prop?, ...}?, Children?) => VNode
With transposition, the signature is a bit more complicated but still strongly typed:
type Prop = string | num |
type ObsProp = Prop | Observable<Prop>
type Child = string | VNode
type ObsChild = Child | Observable<Child>
type Children = ObsChild | [ObsChild] | Observable<[Child]>
type VNode = {value: ObsProp?, ..., children: Children}
h :: (string, {value: ObsProp?, ...}?, Children?) => VNode
Event subscriptions
For a long time in (motor)cycle apps, DOM event streams have been obtained by using DOMSource
, e.g.
const click$ = DOM.select(".foo").events("click")
This approach favors the "traditional" cycle state management where model depends on intent and it has been beneficial to get events "first":
// intent
const plus$ = DOM.select(".inc").events("click").constant(+1)
const minus$ = DOM.select(".dec").events("click").constant(-1)
// model
const sum$ = O.merge(plus$, minus$).scan(R.add, 0)
// view
const vdom$ = sum$.map(sum => ...)
This works pretty well in toy apps, but apps with more complicated state management (real apps IMO) this approach tends to enforce cumbersome and complex code. This has been (finally!) understood in Cycle community, thus libraries like onionify
have been developed.
An another problem in this approach is that there must be a way to isolate events targeted to two or more sibling components. Because the events are coming directly from DOM the only way to distinguish siblings is to make 2-staged isolation: (1) assign some identity to component's root DOM node and (2) filter emitted events based on that identity.
In cycle, isolate
handles this operation:
const IsolatedComp = isolate(Comp, id)
Note that in order to preserve referential transparency ("purity"), id
must given to the isolation. If I've understood correctly, there won't be any isolate
in Motorcycle. This means that the user must perform both of those tasks and define id manually every time when isolation is needed:
const a = Comp({...sources, DOM: DOM.select(".a")})
const b = Comp({...sources, DOM: DOM.select(".b")})
const vdom = h("div", [
h("div.a", a.DOM),
h("div.b", b.DOM)
])
This however, introduces (IMHO) unnecessary boilerplate and requires extra DOM nodes (or even more boilerplate with a.DOM.map(withClass)
) in order to ensure isolation. In addition, events may still "leak" from child component to parent if the parent component subscribes to events by using selector matching child component nodes. This is also more error prone because class selectors must be defined in multiple places.
Monadic event subscription
I see cycle applications just as monadic functions projecting state (over time) into view (over time) and a bunch of control events/actions Main :: Behaviour<State> => [Behaviour<View>, EventStream<Action>]
. How about if we modeled DOM and it's event in a similar way? How about if events could be subscribed directly from vnode
instead of DOMSource
?
// model
function main({onion: state}) {
// view
const vdom = h("div", [
h("h1", state.value.map(name => `Hello ${name}!`),
h("button.send", "Send me!"),
h("input.name", {type: "text", value: state.value})
])
// intent
const click$ = vdom.events(".send", "click")
const type$ = vdom.events(".name", "input")
return {
DOM: vdom,
onion: O.merge(
click$.map(() => () => ""),
type$.map(e => () => e.target.value)
)
}
}
This proposal of course requires that the "official" state management supports circular dependency by default (like onionify
does) because it changes the dependency order from I -> M
to M -> I
.
But what benefits do we get by doing this this? As you might have noticed, there is no dependency to DOM
when defining events, hence we don't need to perform any filtering in our parent component. And because we don't need to do filtering, we don't need to encode any filtering info into our DOM nodes. Parent nodes become extremely simple:
const a = Comp(sources)
const b = Comp(sources)
const vdom = h("div", [ a.DOM, b.DOM ])
Of course there is still a situation where events "leak" from child component to parent. I see two possible alternatives to tackle this:
- Introduce an optional
isolated
function that can be used in parent component if the isolation must be ensured
const a = Comp(sources)
const b = Comp(sources)
const vdom = h("div", [ isolated(a.DOM), isolated(b.DOM) ])
- Do not give any option to use selector when defining events; events may only be defined directly by using the vnode associated with them
function main({onion: state}) {
let send, name
const vdom = h("div", [
h("h1", state.value.map(name => `Hello ${name}!`),
send = h("button.send", "Send me!"),
name = h("input.name", {type: "text", value: state.value})
])
// intent
const click$ = send.events("click")
const type$ = name.events("input")
return {
DOM: vdom,
onion: O.merge(
click$.map(() => () => ""),
type$.map(e => () => e.target.value)
)
}
}
That was my 2 cents. Looking forward to some discussion about the topic. ๐