Closed bdpartridge closed 5 years ago
A solution to this would be to pass the vnode
as a second argument to event handlers, as suggested by @chebum (here: https://github.com/lhorie/mithril.js/issues/1739#issuecomment-289090634 it could be done easily)... Too tired for thinking of drawbacks right now...
This should be doable with the next release which includes factory components:
const component = function() {
let count = 0
function increment() {
count++
}
function view() {
return [
m('p', 'count: ' + count),
m('button', {onclick: increment}, 'Increment')
]
}
return {view}
}
m.mount(document.body, component)
you guys might end up with domvm's API [1], including ES6 classes ;)
@spacejack shouldn't Mithril perform state comparison between view
method calls? Reading docs I thought that it checks if the state changed and then redraws the DOM. How does it work in reality?
@chebum Mithril diffs the virtual DOM trees (a.k.a. trees of vnode
objects). The vnode.state
field is persisted in the tree from one redraw to the next (it is transferred, for each node, from the old vnode
to the new one), but isn't really needed with closure components (previously referred to as "factory components" but the term was deemed confusing).
Edit: Specifically, the engine diffs the tag
, attrs
and children
fields of vnode
objects. The other vnode
fields are there for inner bookkeeping.
@spacejack the closure component solves the issue of simple references to state; the linkEvent
pattern allows you to reference anything available to the view (eg latest attributes).
I can only assume the performance benefit that differentiates this from simple partial application or any other kind of function declaration is special diff logic which allows the render loop to gloss over differences in linkEvent
handlers and pick up the task of applying the function as part of Inferno's event bubbling system.
@bdpartridge reading between the lines I get the impression your main draw from linkEvent
is a formal contract for binding events in views (as opposed to freeform functions). Mithril kinda offers this, albeit in a much more focused way: m.withAttr
is a function for DOM event bindings that ensures a specified property of the DOM element will be fed to a specified function when the event triggers. It's especially useful in combination with stream
. Ironically this was the prescribed method for handling events in Mithril v0.X, but Mithril v1 decided the scope of any given event handler was too large and the formality of invoking extra APIs to achieve simple assignment wasn't worth it. In v0.X, streams were simple getter / setters called m.prop
— the old documentation for m.withAttr
might prove insightful if you're looking for ways to standardise your event handling code.
As regards implementing linkEvent
in Mithril… I think you could only really justify it in terms of performance gains. There are problems in Mithril components WRT expectations of what input is available to any given function, and you're not the only person to prefer documentedLibraryAPI(prescribedSignature)
to myFunction(theArgumentsIWantToGiveIt)
.
I think it'd be good to hash out a (tediously long) document replete with code examples to cover all the aesthetic preferences in combination with all the practical motivations for function bindings in Mithril components. This would at least help us avoid going round the houses trying to remember preferred patterns every time this kinda thing comes up. 🤔
@leeoniya slowly slowly… ;) I was surprised you didn't mention the DOMVM architecture pattern in #1716.
@barneycarroll
Since Mithril's 1.x API is now baked, I think a philosophical discussion is rather moot, plus adding extra signatures and nuances to what exists leads to jquery-level of docs, user confusion and a lot of internal complexity with likely negative perf implications. I try to keep my trolling lighthearted and down to a minimum here; feel free to incorporate any of domvm's ideas.
If anyone still remembers 2015 (600 BC in javascript years), it was Mithril 0.2x's strange "controller", "this" and MVC concepts [1] that led me down my own path. Thankfully that's all behind us 1245 issues later :) It's good to see the Controller finally dead and the speed vastly improved. However even with 1.x, I still notice occasional mithril-isms carried over from 0.2, which is understandable but still not my cup of tea, especially given that I ended up writing exactly the lib i wanted to use.
@chebum to answer your question about redraws, changes in state (vnode.state or otherwise) will not trigger redraws. Redraws are only triggered by events that are added to elements via attrs
and when m.request
s complete. Surprisingly (at least to me when I started using mithril) this covers most cases when you want redraws to happen. For cases that mithril doesn't detect, there is m.redraw()
.
Thanks for the comments.
@pygy I like the idea of passing vnode
as the second argument to event callbacks. It's simple and easy to document. Since I really just need access to vnode.state
and vnode.attrs
, this solution could work.
@spacejack with factory components, how would I access vnode.attrs
in a nested component without creating a new function in view(...)
?
@barneycarroll yes, I'm specifically interested in getting access to vnode.attrs
(and vnode.state
). But, I should've been clearer about the main thing that appeals to me about the linkEvent(...)
approach. While I think it looks cleaner to not use .bind(this)
or arrow functions, the main appeal for me is the potential for performance improvement (arguably unproven). We're using mithril v0.2.x in a fairly large production app at my current employer, and we've avoided recreating callback functions on every render because it seems needlessly wasteful. Maybe it is a micro-optimization, but I would still argue for the ergonomics of providing a utility function or just passing in vnode
as a second argument to the callback, as @pygy suggested.
I like best @pygy's proposal of just passing vnode
as a second argument. That would fix this with no practical performance overhead, and it doesn't have the API boilerplate issues that Inferno's solution does (you don't have to explicitly call linkEvent
to get the same benefits).
Giving credit where it's due, the idea comes from @chebum!
nice. that's a much simpler approach to an issue for consideration a while back -- https://github.com/lhorie/mithril.js/issues/1484#issuecomment-267860717.
@cavemansspa The nicer thing about it is that we wouldn't have to add anything extra. You could just do it and access the config
via vnode.attrs.config
😄
I am not sure if this is a bad practice, but what do you think about this:
// Main class to set vnode property
class MainClass<A> {
private _vnode: m.CVnodeDOM<A>
get vnode() {
return this._vnode
}
render = (vnode: m.CVnodeDOM<A>) => { return m('span') }
view(vnode: m.CVnodeDOM<A>) {
this._vnode = vnode
return this.render(vnode)
}
}
// And after that - just beautiful syntax with vnode accessible everywhere
class TestButton extends MainClass<{ value: number, onclick: Function }> {
onclick = () => {
// Extend behaviour, or override
if (this.vnode.attrs.onclick) this.vnode.attrs.onclick()
console.log(this.vnode.attrs.value)
}
render = () => {
return m('button', { ...this.vnode.attrs, onclick: this.onclick }, this.vnode.attrs.value)
}
}
@vladpazych It's an equivalent workaround, but it'd be better to just pass a second argument.
Implemented @chebum's idea of passing vnode
as a second argument here.
Welcome!
A little late here (sorry), but passing the vnode as a second arg seems like a bad idea to me. We're making mithril event handlers proprietary and I disagree that adding an additional argument isn't a major semver release, I have code that will throw with this change.
This reminds me of passing the index in Array::map
, it seems convenient, but it actually discourages composition and leads to sometimes surprising behaviour.
To illustrate my point, here's an example of passing additional args radically changing behaviour.
R.map( R.slice, range(0,10) )
has very different behaviour to R.range(0,10).map( R.slice )
, the first creates a list of functions that slice from a particular index, the second fully evaluates slice because the index, and the list are provided to the Array::map
visitor function.
R.range(0,3).map( R.slice ) //=> [[],[],[]]
R.map( R.slice, R.range(0, 3)) //=> [R.slice(0), R.slice(1), R.slice(2)]
I also don't see what problem this solves, vnode is always going to be in scope, so we're diverging from mithril's thin wrapper over the DOM API for little benefit. This notion of avoiding oninit
/controller
by spreading coupled logic into various hooks and event handlers mystifies me. On the quest to avoid "fat controllers" we end up with spaghetti code, in denial of its truly coupled nature.
I can mitigate this change with a helper that forces the function to be unary, but its awkward, and I wanted to voice my objection. I expect this will be a change we end up regretting later, like prop's having a .then
method. It seems convenient, but it is surprising and makes mithril more complex.
If this improves performance, we should have benchmarks before making decisions about the value it adds. It may be in the noise, and unless its a significant boost I don't agree its a valid trade off. Ironically, if one wants to avoid that second argument we're forced to create an intermediate function, which is the exact problem this feature is meant to solve.
I also don't see what problem this solves, vnode is always going to be in scope
i'm no mithril expert, but it seems that only the component's root vnode would be in scope, but not the vnode of a handler defined deeper in the returned vtree. right?
or do you guys hang the current vnode off e
, e.target
or this
?
At the risk of offending my coworker, @bdpartridge, who filed this issue, I agree with @JAForbes. Encroaching on a DOM API has a bit of a code smell.
Factory components seem to solve the problem, at least for the common case.
@spacejack the closure component solves the issue of simple references to state; the linkEvent pattern allows you to reference anything available to the view (eg latest attributes).
@barneycarroll, accessing the latest state or attrs is easily solved by stashing a reference in a component-scoped var each time view
is called.
@spacejack with factory components, how would I access vnode.attrs in a nested component without creating a new function in view(...)?
@bdpartridge, I’d love to see a code sample illustrating the nested component situation you’re describing. Is it common?
Could someone clarify what they mean by "stale attrs". I'm still on 0.2x day to day so apologies if I misunderstand something, but aren't attrs bound at component creation time, and aren't updated every redraw?
var root = document.body;
function Parent(){
return {
view(){
return m('div'
,m(Child, { value: Math.random() })
,m('button', { onclick: i => i }, 'Redraw')
)
}
}
}
function Child(){
return {
view(vnode){
return m('p', JSON.stringify(vnode.attrs) )
}
}
}
m.render(root, m(Parent));
In the above example, my understanding is, we'll always render the same random number, no matter how many times we redraw, even though the Parent
component appears to be passing a new random number on every render.
That's my understanding. That said, receiving vnode.attrs
fresh every render would be a great thing in my opinion, it would solve a lot of problems I have with stale components that need to be instantiated to receive new data, I currently solve that by either using a stream that is passed down, or by using a plain old function (where possible).
So is my understanding wrong? I just tried the above code in a bin and it seems to follow my expectations. Could someone elaborate what "stale attrs" means in their context?
Nope, you get updated attrs every redraw: http://jsbin.com/caxosogeqe/edit?html,js,output
@spacejack oh wow! 🎉
Just found out why my example code failed, the template was using m.render
instead of m.mount
😮
But yeah as @spacejack's bin shows, vnode
is in scope, we get the fresh value, and I still oppose this change.
I think the use case is like this:
var Comp = function() {
function move(e) {
// I want fresh attrs here...
}
return {
view(vnode) {
return m('div', {
onmousemove: move
},
"Test"
)
}
}
}
What about tacking vnode on to the event? The event is already quasi-proprietary in that you set event.redraw=false to prevent redraws.
I agree that tacking vnode onto the event would be less intrusive than passing it as an extra arg.
However, this specific case is easily handled. Here’s what I meant by “stashing a reference in a component-scoped var”:
var Comp = function() {
var attrs;
function move(e) {
// Use attrs.
}
return {
view(vnode) {
attrs = vnode.attrs
return m('div', {onmousemove: move}, "Test")
}
}
}
If others are asking for access to the event target’s vnode, well, what if what you actually need is the vnode of some element/component between the element with the handler and the event target?
The reason .target
, .currentTarget
, etc. work well in the DOM API is because the DOM tree is navigable (e.g. using .parentNode
). I could be mistaken, but the virtual DOM tree doesn’t appear to be. I do see vnode.children
, but no way to get a vnode’s siblings or parents.
One way to address this issue would be for mithril to expose a mapping from real DOM nodes to vnodes. Event handlers that rely on capturing or bubbling could use m.vnode(event.target)
or m.vnode(event.target.parentNode)
or m.vnode(event.relatedTarget)
, etc.
I think the ref trick is valid, but I personally think this would usually be premature optimization, instead of having a ref to attrs that we iteratively update, I'd use a curried function. I do this everywhere, and it hasn't made a dent on performance.
// in the closure / etc
const move = vnode => e => { ... }
// in the view
{ onmousemove: move(vnode) }
If someone's actually running into performance issues then I'll acquiesce, but the above is what I would do, and if I did have performance issues I'd do what @2is10 is doing (but only if that component is on a hot path, and making the change had a measurable difference)
I've just been having some interesting discussions with @spacejack in the gitter, and I've discovered 1.0 has behaviour I was completely surprised and unaware of.
I'm surprised the vnode
reference changes from render to render, I expected a fully qualified vnode.attrs
from the outer closure would grab the latest attrs, but apparently vnode
itself is recreated each draw and state
is attached to it. That's kind of fascinating, and shows I really need to get acquainted with 1.0. I imagine that behaviour is there to unify component vnodes and regular hyperscript vnodes.
I'm not sure why we'd want access to the vnode of an arbitrary element within the vtree of a component, as opposed to the vnode of the component itself. Could someone explain why that would be useful?
@JAForbes Could you come up with a concrete proposal for how it could be better done? Considering the existing vnode
-as-second-argument is already in next
, it'd have to be detailed and implemented quickly so it can make the next release without making a net breaking change (because people will start using the solution as soon as it gets published).
@isiahmeadows Can't we just roll back the PR, it hasn't been released yet right?
Also I think this is adequate:
// in the closure / etc
const move = vnode => e => { ... }
// in the view
{ onmousemove: move(vnode) }
After thinking about this more I'm with @JAForbes in not loving this solution.
I prefer either recommending that people store a ref to vnode
within their scope or making e.vnode
a thing. I prefer the version that doesn't require mithril changes :smile:
IMO taking the PR out of next
isn't a big deal, it's only a problem once it goes to master
. We're still about a week out from discussing whether or not we want to cut 1.1.2
or 1.2.0
so there's still time.
I'm not opposed to alternatives, and I appreciate the drawbacks of having a proprietary event handler callback. For the record, I originally suggested we add a helper utility like Inferno's linkEvent(...)
to enable arbitrary data passing, but on further thought, I realize that this doesn't really give me want I want, which is simply a way to access the latest attrs
without having to resort to higher order functions, or stashing vnode.attrs
from view(...)
, or any other boilerplate.
Let me illustrate what I want to achieve with some examples:
As @2is10 alluded to, this is an example of what we've been doing in mithril 0.2.x to provide event handlers with the latest attrs
. We're essentially stashing them in ctrl
:
const ParentComponent = {
controller: function () {
let ctrl = {
showSuccessTime: 500,
onchange: (event) => {
ctrl.showSuccessTime = event.target.value;
},
};
return ctrl;
},
view: function (ctrl) {
let showSuccessTime = ctrl.showSuccessTime;
return m("div",
m("input", {value: showSuccessTime, onchange: ctrl.onchange}),
m(ChildComponent, {showSuccessTime}));
},
};
const ChildComponent = {
controller: function (initialAttrs) {
let ctrl = {
attrs: initialAttrs,
showSuccess: false,
onclick: () => {
ctrl.showSuccess = true;
setTimeout(() => {
ctrl.showSuccess = false;
m.redraw();
}, ctrl.attrs.showSuccessTime);
},
};
return ctrl;
},
view: function (ctrl, attrs) {
ctrl.attrs = attrs;
return m("button[type='button']", {onclick: ctrl.onclick}, ctrl.showSuccess ? "Success!" : "Click me");
},
};
m.mount(document.getElementById("app"), ParentComponent);
https://jsbin.com/vuhecas/edit?js,output
Here's what I want to do in the latest version of mithril:
const ParentComponent = function () {
let showSuccessTime = 500;
function onchange (event) {
showSuccessTime = event.target.value;
}
return {
view: function () {
return m("div",
m("input", {value: showSuccessTime, onchange}),
m(ChildComponent, {showSuccessTime}));
},
};
};
const ChildComponent = function (vnode) {
let showSuccess = false;
function onclick() {
showSuccess = true;
setTimeout(() => {
showSuccess = false;
m.redraw();
}, vnode.attrs.showSuccessTime);
}
return {
view: function () {
return m("button[type='button']", {onclick}, showSuccess ? "Success!" : "Click me");
},
};
};
m.mount(document.getElementById("app"), ParentComponent);
https://jsbin.com/tozibew/edit?js,output
In the second example, updating showSuccessTime
with the <input>
doesn't work because the reference to vnode.attrs
in the closure component is stale.
Yes, there are workarounds like stashing and currying, but these add little bits of what I consider to be boilerplate in every component where this is needed. In the codebase I work on currently, access to the latest attrs
in event handlers is needed extensively.
It seems to me the fundamental problem is that the object from which attrs
is attached is a vnode. Vnodes do not have a strong conceptual mapping to a component instance (at least in a way that matters for this context).
Component instances can be thought of as persistent entities that are around as long as their parent continues to render them, while the vnode for a component is discarded and rebuilt every single render. What if instead of passing in vnode everywhere, we passed a persistent object representing the component instance, and every render loop this object is updated with fresh references to the vnode
, attrs
state
before passed to any lifecycle methods?
This would give us the following semantics:
// The name `obj` is not a good name, this is just for demonstration
oninit: function(obj) {
var func1 = ()=> console.log('obj.attrs could be kept fresh by mithril:', obj.attrs);
var oldAttrs = obj.attrs;
var func2 = ()=> console.log('This is a deliberately created old copy of attrs:', oldAttrs);
}
We almost always want fresh attrs, and it should be easy and natural to do so without any extra boilerplate. Getting old attrs from previous render loops should require extra work to stash them somewhere -- it shouldn't happen without deliberate effort. I think it may be a design smell that it is really easy to mistakenly create a closure over a stale vnode that has old attrs.
As @bdpartridge points out, this doesn't happen in 0.2.x
, as vnode is not a concept. Everything lives on the controller (or is some other abstraction that the dev has full control over).
Disclaimer: I haven't written much code with mithril v1
yet, so there is a decent chance I don't have the full picture. But I've read many of the github issues and gitter discussions, so hopefully my comment makes sense.
Edit: Reword for clarity & a few semantic tweaks to better express what I mean. Added a code exmaple
@tivac @JAForbes I reverted the change for now.
@dwaltrip
Component instances can be thought of as persistent entities that are around as long as their parent continues to render them, while the vnode for a component is discarded and rebuilt every single render. What if instead of passing in vnode everywhere, we passed a persistent object representing the component instance, and every render loop this object is updated with fresh references to the vnode, attrs state before passed to any lifecycle methods?
Until just recently I thought this was existing behaviour and I was quite surprised we get a new vnode every render. I'd really like to hear the justification for this design before critiquing it, but what you're suggesting seems an improvement to me, particularly now that closure components are a part of mithril 1.x. And this change would fix this particular problem, you could re-use existing handlers without losing a reference to fresh attrs
.
Are there any issues where this decision was discussed that I could read up on? Or was this emergent behaviour that was simply never opted out of for components?
It seems potentially dangerous to have a persistent reference to vnode
in oninit
that has no relationship to the vnode
in subsequent draws.
@JAForbes
Until just recently I thought this was existing behaviour and I was quite surprised we get a new vnode every render.
I wrote up on this earlier from a performance viewpoint (see specifically suggestion 3 of this gist), but it really just comes down to mildly sloppy coding inherited from the beginning of the rewrite IMHO, and it is surprising behavior.
A patch for this could be non-trivial, but it'd be worth it, both for reduced object lifetimes and just general sanity.
@JAForbes -- i recall a a related conversation on vnode
here: #1521
React's solution to this problem is to have this.props
always point to the latest values that were passed into the component, where this
is a persistent object representing the component instance.
A summary of the thread @cavemansspa referenced for consideration.
@lhorie:
Pros: it makes the actual behavior be in line w/ the developer's (flawed) expectations. Cons: this is voodoo magic
@barneycarroll:
I'm withdrawing the proposal to make component vnodes persistent objects because that treads on the toes of the update methods, which rely on newVnode, oldVnode signatures.
@brlewis:
Making changes to the mithril codebase in order to hide the fact that vnodes don't persist through the entire lifecycle won't work. People will still encounter confusion, only later and in more subtle ways.
I don't have enough experience using 1.0 to feel comfortable challenging any of the above arguments from the other thread. I still oppose an extra argument for event handlers and it does seem like a smell to me to have event handlers accessing mithril specific properties on an event.
I personally think that currying the handler is a great solution, and worrying about performance for creation of functions is untested and probably misguided - but I can understand the frustration of having to do that every time, it feels like something that should be supported somehow in the default API without any trickery.
If anything closure components are more susceptible to this particular annoyance. I think when I eventually migrate to 1.0 I'd decorate the component interface so I get a stream of "fresh" attrs that I can subscribe to and merge with other event streams in the closure. But streams aren't part of the core library so that isn't a solution everyone else is likely to want to adopt.
Of the three proposed solutions, I don't think there is a satisfying addition, but adding a property to the event seems like the path of least resistance, so if we have no other options and we want to make some kind of change then I'd reluctantly support that.
As @spacejack has stated We're already modifying the event object to have a redraw=false
property, so from that perspective its consistent. If we're modelling mithril event handlers in typescript or flow it's already going to be an intersection type.
But if someone has a different solution I'd welcome that 😄
To be clear, what @dwaltrip suggested is different from what was proposed in that other thread.
@dwaltrip (this thread):
What if instead of passing in vnode everywhere, we passed a persistent object representing the component instance, and every render loop this object is updated with fresh references to the vnode, attrs, state before passed to any lifecycle methods?
@barneycarroll (other thread):
We can mitigate this by changing render logic such that the vnode received by components — which need not necessarily be the same construct used by the render internals — be stateful, this ensuring that the vnode received by any given component instance will be the same throughout that component's perpetuity.
In addition, that other thread predates closure components. @lhorie’s harsh criticism, in particular, was about the idea of mutating the vnode passed to oninit
for a “developer who misundertands how closures work”.
The closure component API might have benefitted from a little more thought about how closures work. That new API would have been a natural place to introduce a new object type—one whose properties are always up-to-date:
const ChildComponent = function (o) {
// Use o.vnode, o.attrs, o.state (each always up-to-date).
}
I’ve shown it as o
above as a pictogram of the One ring and as a symbol of renewal. Also has the nice effect of making each property access sound poetic. :)
Or could’ve passed a latest-vnode getter instead of just the initial vnode:
const ChildComponent = function (getVnode) {
// Use getVnode().attrs for up-to-date attrs.
}
While I like @dwaltrip’s suggestion and the event.vnode
suggestion, here’s one more concrete, easy (?), small, backwards-compatible possibility to consider: Pass a latest-attrs getter as an additional arg to closure components. People could name it however they please (e.g. getLatestAttrs
, getAttrs
, attrs
).
const ChildComponent = function (vnode, attrs) {
...
function onclick() {
showSuccess = true;
setTimeout(() => {
showSuccess = false;
m.redraw();
}, attrs().showSuccessTime);
}
...
}
@JAForbes As someone who has done significant hacking on the renderer itself, the difficulties of making the vnode persistent is generally overstated. The main requirement is that you understand the renderer algorithm (which only a few of us do), but beyond that, it's mostly name changes, and it's something you can generally do for components and DOM vnodes separately. It's more tedious than it is hard.
One more point for having some way to access to the current vnode in an event handler is getting the current dom (which isn't available in the vnode provided to view
.)
@spacejack It's inadvisable to access vnode.dom
in the view, anyways - it's not there during the first render, and it doesn't see the updates from the current render (and is likely to get interfered with).
I mean in an onclick etc. handler.
... getting the current dom (which isn't available in the vnode provided to view.)
@spacejack actually, the vnode
is the same in all hooks of a given redraw()
cycle, it is mutated after the view
returns. @isiahmeadows I don't think it ever has the .dom
set until the view returns...
I suppose you can get the clicked element via event.currentTarget
(maybe need to crawl through the dom for the element you want) so that's not so bad. However there's this case:
const c = {
view() {
m(comp, {
onFoo: () => { /* want to use dom here */ }
})
}
}
EDIT: Sorry my brain isn't working today... that's not a mithril event handler duh. I suppose the dom access is mostly a non-issue.
@spacejack This is what you meant? Looks like it could cause issues as you were suggesting. Although this approach could be somewhat inherently precarious, I can imagine a few situations in which it might make sense. I've found that these edge cases come up more frequently when you are doing things with 3rd party libraries. Of course, there are work arounds, but it usually involves a bit of extra code & manual bookkeeping.
const FooComponent = {
view(fooVnode) {
return m('.foo-div', m(BazComponent, {
handleSomething: () => { /* want to use FooComponent dom. using fooVnode won't work */ }
}));
}
}
I really like Inferno's
linkEvent(...)
helper function (see doc), and I'm hoping that others also have interest in seeing a similar function get added to mithril.It just feels bad/strange to have to recreate anonymous/arrow functions (or use function binding) to enable passing data from a component to event callbacks. Adding first-class support for a utility function like this provides an alternative that some may find cleaner. As a bonus, the Inferno doc claims a performance boost from this approach. I haven't verified the performance difference, but given the nature of closures and function binding, it makes sense.
Given the mithril doc's recommendation to "avoid fat components", a utility function like this seems like a natural fit since it helps to enable keeping state manipulation code separate from components. How about adding a similar helper function to mithril? Is there any interest? If so, I'd be happy to start working on it.