MithrilJS / mithril.js

A JavaScript Framework for Building Brilliant Applications
https://mithril.js.org
MIT License
14.03k stars 924 forks source link

add React Context API similar feature #2148

Closed futurist closed 3 years ago

futurist commented 6 years ago

Expected Behavior

Mithril now still conformed to the data is passed top-down way, since the React Context API come to live, it's possible to grab some idea.

Consider the same scenario of locale preference, UI theme etc., for a mithril predefined component lib, this feature is important to apply vnode/state into arbitrarily level of deeply nested sub-components.

Current Behavior

Currently, it's commonly 2 ways to do so:

  1. Break apart from the whole vnode tree system, and using a global model, imported then applied to sub-component manually.

  2. Store the top level/desired level vnode references, and consume data from sub-component using these references, which is a bit anti-pattern.

Possible Solution

The React Context API is a good design pattern to consider, it's Provider/Consumer pair that the Consumer will lookup the closest Parent Provider for vnode data, and invoke a children as function as the result.

This similar way, if there's a context concept or similar thing specific to mithril, and that thing is managed by mithril system compared to current solutions (which is manually managed by user), will make good for better encapsulated components, and good for component development from community.

CreaturesInUnitards commented 6 years ago

I agree that the context API is a nice pattern, but IMHO this is a userland concern. Enforcing an opinionated structure of this kind is antithetical to much of Mithril's basic philosophy.

pygy commented 6 years ago

I don't think this can be fully implemented with Mithril as it is today. There's no way for a vnode to look up the ancestors hierarchy...

We could add a parent field on vnodes that would allow the Consumer components to look up the tree for the nearest Provider in the parent chain. It has already been requested in the past.

That's probably the simplest way to make it possible to implement that feature.

spacejack commented 6 years ago

Just a couple of thoughts on this.

React "needs" the context API (or some 3rd party wiring like Redux) because redraws are tied to component state, while Mithril redraws are not.

Would a vnode parent reference affect garbage collection by adding circular references within the tree?

barneycarroll commented 6 years ago

I'm against the idea. It can be implemented but shouldn't: it's unnecessary and drastically adds to the surface complexity of Mithril's vnode API. As @pygy points out React needs this as a workaround for initial API design that Mithril solved the first time round. In practice Mithril can and should achieve the same practical effects through the attrs API.

futurist commented 6 years ago

Enforcing an opinionated structure of this kind

It's just an idea to lookup from, not meant to be same as context API, just see what's mithril can do for parent lookup.

We could add a parent field on vnodes that would allow the Consumer components to look up the tree for the nearest Provider in the parent chain

Yes! but also maybe as a global method if .parent is too heavy?

m.closest ( vnode, parentVnode => isProvider(parentVnode) )  // return provider

Would a vnode parent reference affect garbage collection by adding circular references within the tree?

How the vnode parent circular? does each vnode only have only one parent? Addition, the vnode.parent could be dereferenced easily after onremove.

leeoniya commented 6 years ago

FWIW, domvm's vnodes have .parent circular references and GC is fine. the only thing i found that causes leaks is leaving dom -> old vnode references after the element has been removed.

also, i think react's context api auto-redraws relevant components that use a consumer whenever a provider's value is updated. so it's a bit more than just getting a value from an ancestor in the vtree, it's also a micro pub/sub.

pygy commented 6 years ago

@leeoniya In what browser are there leaks in such circumstances? I remember IE6 having two GCs and DOM <=> JS circular refs causing leaks, but I was not aware of similar circumstances with modern browsers.

leeoniya commented 6 years ago

probably worth re-testing again but it was a doozy to narrow down at the time [1] as none of the old vtree should have been reachable, though it's difficult to say with absolute certainty. i tried a lot of things but dereferencing the old vnode from the dom element is the only thing that worked.

[1] https://github.com/leeoniya/domvm/issues/164

starsolaris commented 6 years ago

React "needs" the context API (or some 3rd party wiring like Redux) because redraws are tied to component state, while Mithril redraws are not.

Can you describe this?

CreaturesInUnitards commented 6 years ago

Mithril performs a diff/redraw on route changes, ajax resolutions and user events, irrespective of state changes.

futurist commented 6 years ago

When regard redraw, react have no manually option like m.redraw, but only via prop/state changes, this both have pros and cons, and both works in their way, that's why react need Context API, since it depends on the prop/state change heavily.

The context in this issue is mainly for virtual dom thing, that is mithril vnode don't know the context around it internally (only from parent), React also don't before v16.3.0, until the Context API come alive.

Mithril's good part is very integrated with JS itself, that's good, but vnode data source can only from {attrs, children}, and only from parent, out of that, you have to manage it in JS. That is the context come from, the design and thinking of virtual dom.

Think I'm making a button component, that anyone can include:

//export ThemedButton
class ThemedButton {
  oninit(vnode){
    this.theme = getTheme(vnode)
  }
  view() {
    return m("button", {class:this.theme}, `CLICK`)
  }
}

function getTheme(vnode){
  // how we know the root theme?
}

Currently we can pass {attrs} from parent, so every time include this component, you have to set theme from parent, or pass in theme data from insertion point in JS, both require developer connect things manually.

The idea is, if root theme is dark, then this button is also dark theme, when changed theme, this button can Context Aware, and no need developer do work from insertion point or get reference from global model, thus reduce the work for UI, focus on data model and logic.

This may help this component encapsulated, and the context aware virtual dom can help mithril component market more blooming maybe.

futurist commented 6 years ago

The other idea is to give each vnode a reference of root, it can be m.mount root component vnode, user can query this rootVnode for their needs.

Since rootVnode is only 1 per m.mount, no need to manage reference/dereference between vnode, just set to vnode.root and it's safe.

Below demo code as inspiration: DEMO HERE

function queryVnode (vnode, query, depth, store) {
  store = store || []
  depth = depth || 0
  if(Array.isArray(vnode)) {
    vnode.forEach(function(v){
      queryVnode(v, query, depth, store)
    })
    return store
  }
  if(!vnode || !vnode.tag) return
  if(depth >= (query.from|0)) {
    if(query.test(vnode) && store.indexOf(vnode)<0) store.push(vnode)
  } else {
    console.log(depth, 'skip')
  }
  if(query.depth > depth) {
    queryVnode(vnode.children, query, depth+1, store)
  }
  return store
}

var rootVnode = m('div', m("button", {className:'abc'}, `CLICK`))
var find = queryVnode(rootVnode, {
  depth: Infinity,
  test: vnode=>vnode.attrs && vnode.attrs.className==='abc'
})
console.log(find[0])
pygy commented 6 years ago

@futurist for the theme thing, at some point you'll be able to rely on CSS variables that are dynamically scoped according to the position of an element in the DOM.

If you want to use it today (with fallback to the default theme in browsers that don't support variables), it is a bit more complex though.

color: #f00;
color: var(--someVar, #f00);

IIRC the support for CSS variables in vdom could be improved in Mithril.


Your queryVnode API can be implemented today using a helper:

export function makeVnodeQueryHelper() {
  let vnode
  return {
    setRoot(root) {vnode = root},
    search(query, depth, store) {
      return queryVnode (vnode, query, depth, store)
    }
  }
}

You can then use vnodeQuery.setRoot(vnode) in the oninit and onbeforeupdate hooks of your root components.

The Big O complexity is not pretty though (O(N) where N is the total number of vnodes in the tree un to depth).

futurist commented 6 years ago

@pygy not for theme thing, just because React Context API doc page use ThemedButton class as example and I'm taking the same example.

Helper is ok for me, but for the above example ThemedButton, how could it know the root when oninit? Set root from root component and then Get the reference from button component, means the ThemedButton cannot be encapsulated, and rely on external data.

I suggest mithril can add a root prop to vnode API, that way things should be easier.

pygy commented 6 years ago

@futurist for encapsulation, you could inject the vnodeQuery dependency into your component.

// myComponent.js
export default function(vnodeQuery) {
  return {view(){...}}
}

// app.js
import vnodeQuery from "..."
import myComponentFactory from "./myComponent"
const myComponent = myComponentFactory(vnodeQuery)
foxdonut commented 6 years ago

@futurist I still need to do a write-up, but FWIW, with Meiosis I have a React Context example which simply uses a designated property on the model (context in this example) which is automatically passed down to components when nesting. That way, parent components can continue to call child(model) to render children, without needing to "know" that the context is also being passed down. I think it's quite simple and straightforward.

N.B. If you scroll down the page, you will find the Mithril version.

esrch commented 6 years ago

One pattern that the context API enables is Compound Components. They make for very reusable components, and they are somewhat difficult to achieve without something akin to the context API. If it is not too difficult to do, I would be very much in favor of adding a reference to the parent on the vnode, to enable such a pattern.

A typical example of a Compound Component is a Tabs component, with associated TabList, Tab, TabPanels and TabPanel components. I have made several examples for tabs in mithril using different patterns:

Example 1: Manual tabs. Tabs are created "manually", by setting an activeTabIndex at the App level. I can build everything I want like this, but there is no abstraction, and I have to wire everything by hand.

Example 2: Simple Tabs component. This abstracts the logic of managing the active tab index. This is great, but fixes the structure of the tabs, e.g., if I want to have the tabs below the panels, I have to write an alternative Tabs component.

Example 3: Tabs compound component (no context API). This allows more flexibility, since I can choose whether I want to put the tabs on top or on the bottom. However, I am restricted to using a fixed structure for the elements of the compound components, e.g., TabList and TabPanels must be direct children of Tabs, otherwise everything breaks.

Example 4: Tabs compount component (with context API). This is the most flexible solution, as it allows any level of nesting, and the compound component still works. I had to create and use an alternative mc function instead of the usual m to add a parent to the vnodes, and this still required some hacks. I don't know whether it would work in all cases.

Other examples of compound components might be :

I don't think that a "root" or global context would work that well for compound components, since these might appear in several places within a page, or even be nested inside each other, and each would need to keep its own state.

Also, building these examples made me sceptical that the problem can be fully solved in userland only, given the hacks I had to do to make it work, and I am sure that I didn't cover all edge cases.

barneycarroll commented 6 years ago

@esrch thanks for your explanation of compound components - until now I hadn't really grokked why they were structured the way they're structured. Here's an alternative implementation using child functions (aka 'render props')..

I think this is better than the other implementations for a few reasons:

  1. It's a much smaller surface API. The only export is the Tabs component, and it in turn exposes Tab & TabPanel components
  2. ...which rules out a class of nonsensical invocations, eg a TabPanel without a Tabs ancestor
  3. ...but also allows more flexibility, for instance nested or interspersed Tabs instances
  4. Unlike @CreaturesInUnitards's implementation, it doesn't require the consumer to take care to bind up all the stateful properties
  5. ...But we could introduce eg explicit tab identification (instead of automatic indexing) via an attribute flag on the Tabs component itself
  6. The logical dependency relationships are explicit & can be traced through reading the call-site code and stepping through it in pure JavaScript - no more invisible state propagation

'Compound components' don't need context, it's just that React culture has the context API sitting there so it feels 'idiomatic' to make use of it (presumably this is more 'specific' to the use case than higher-order components or render props).

The scenarios that aren't feasible without context are those in which you want to pick up on shared state in separate scopes with no explicit references between the two (for example, you call Tabs in one file, then call TabPanel in another). This is IMO wholly undesirable for the purposes of compound components: it creates harder to debug ambiguity (where was this set?) and forbids scenarios like nesting where explicit references wouldn't present a problem. The scenarios context originally helped with - passing application state downwards without the need to explicitly pass attributes from ancestor to descendant - are IMO a crutch to counteract the fact that JSX and the raw JS React API make expressing JavaScript in views extremely cumbersome. If React didn't insist on key in generated lists and rebinding event handlers in constructors, the onerous task of simply explicitly passing model data through the props interface wouldn't seem like such a terrible thing.

The class of problems introduced by context are similar to CSS inheritance, inasmuch as the DOM tree structure becomes a model unto itself, a way of avoiding explicit references at the cost of imprecise and increasingly difficult to manage 'declarations' of state. Atomic CSS & functional views via virtual DOM nominally enable us to step out of that problem space by offering total flexibility & explicit, granular precision.

EDIT: expanded the Tabs component functionality to allow named tab references and initial tab state declaration.

esrch commented 6 years ago

@barneycarroll Your solution is indeed better, and the reasons you give make sense to me.

As a side note, it was very interesting to read how you wrote your components, I learned a lot from it, thank you.

foxdonut commented 6 years ago

@barneycarroll that is brilliant!

benmerckx commented 6 years ago

I find react's context very useful exactly for what is mentioned in the original issue here: i18n and theming. Compound components or render props are great for almost anything else, but they offer little help when you need access to the current language or theme somewhere deep down. I tried to create a copy of the react context api here, just to see how far I could get: https://github.com/benmerckx/mithril-context/blob/master/index.js It's simple and will possibly break, but I'll put it to the test soon.

leeoniya commented 6 years ago

i kinda forgot about this thread until recently and basically ended up with the same solution in domvm as @futurist proposed above in https://github.com/MithrilJS/mithril.js/issues/2148#issuecomment-388592079 - a simple vtree crawler that can be implemented in userland (https://github.com/domvm/domvm/issues/202).

https://jsfiddle.net/s9cz2wu3/

a nice property it has over @barneycarroll's solution is that the shared stuff can live as high or low as necessary without imposing a specific compund component api (one that provides scoped Tab and TabPanel components) on all contents. certainly, each strategy has its trade-offs, but the vtree crawler is more generalized and simple, i think.

EDIT: a drawback I see to the vtree crawler is the same as you would have with createContext - it has to be done a priori rather than ad-hoc as in @barneycarroll's solution, which essentially means free state construction/destruction. for i18n and theming, imperative construction is usually ok, since they tend to be singletons, whereas there can be many child components that need shared but insulated state. so definitely valid use-cases for both strategies.

EDIT 2: maybe the vtree crawler is actually sufficient: https://jsfiddle.net/kvd1a954/

dead-claudia commented 6 years ago

BTW, this came up independently when I was looking to come up with a better m.route API, probably for v3 (maybe v2). In particular, it lets me specify subroutes and links in a way that is composable, supports async loading, and lets third-party modules play nice, complete with their own subroutes.

I'm not 100% sold on the m.rotue API changes as proposed there, but the m.context.* API there isn't that bad. Here's a quick summary of what I was thinking:

Keys would be object keys, so it's pretty easy to implement. And the context itself would be something like this, mod allocation-avoiding optimizations:

var rootContext = null

// Called when processing `m.context.set`
function pushContext(parent, keys) {
    if (keys == null) return parent.context
    return Object.freeze(
        Object.assign(Object.create(parent.context), keys)
    )
}

// Called when processing `m.context.get`
function extractContext(callback, keysList, parent) {
    var keys = Object.create(null)
    for (var i = 0; i < keysList.length; i++) {
        keys[keysList[i]] = parent.context[keysList[i]]
    }
    return callback(keys)
}
dead-claudia commented 6 years ago

I'm committed to this once #2219 gets implemented and I clean up the excessive arguments passing in the renderer (to reduce stack space - I'll benchmark it first).

yossicadaner commented 6 years ago

I'm a little late to this. I think this idea breaks mithril's philosophy of an unopinionated framework. This sure is.

The beauty of mithril is that can take many design paradigms and use it in mithril. Context API is not a generic solution that applies to the common app. Sure, it'd be great to have a library 'm.context' like any other.

The Context idea is easily implemented in mithril (if you have in mind at start of projects. I believe an immutable key/value structure that is passsed down the hierarchy provides the same function.

It would be nice to have a global immutable object that gets passed to all descendants vnodes "automatically"

yossicadaner commented 6 years ago

BS"D

Possibly Mithril can allow something like m(elm, {tochildren:{...keys}) which gets passed to all nested children down that chain. I find this solution simpler to implement, easier to reason and cleaner. Then have a global m.context that needs to bind to the context every time. vnode.tochildren can be accessible to nested children.

dead-claudia commented 6 years ago

I'm a little late to this. I think this idea breaks mithril's philosophy of an unopinionated framework. This sure is.

The beauty of mithril is that can take many design paradigms and use it in mithril. Context API is not a generic solution that applies to the common app. Sure, it'd be great to have a library 'm.context' like any other.

The Context idea is easily implemented in mithril (if you have in mind at start of projects. I believe an immutable key/value structure that is passsed down the hierarchy provides the same function.

It would be nice to have a global immutable object that gets passed to all descendants vnodes "automatically"

BTW, that's literally what I'm proposing, just with a catch: I'd rather avoid doing too much allocation in the process. A previous rendition was this, but it would've made it unusable for Meiosis users and others who don't really use Mithril's internal component functionality:

I may still choose to expose it that way, but by abstracting it out completely, I'm free to just allocate and manage context while rendering. It also remains less opinionated on how you structure your views. What we're wanting to do to the router falls under a similar boat - making it accessible to those who don't use Mithril's component functionality.

Possibly Mithril can allow something like m(elm, {tochildren:{...keys}) which gets passed to all nested children down that chain. I find this solution simpler to implement, easier to reason and cleaner. Then have a global m.context that needs to bind to the context every time. vnode.tochildren can be accessible to nested children.

I'd have to open a separate channel either way - either I do it while normalizing (for your idea) or while rendering (my idea). It might seem simpler to implement, but it'd easily triple the size of this code, and that's a pretty performance-sensitive area. And if I'm going to open a separate channel, I'd rather not waste more memory and CPU cycles than I need, and it's not like it's that much more code to just do a bunch of Object.create(parent) internally - a lot of the size gain would just be adding an extra argument to about a dozen functions, and GC would take care of the rest.

dead-claudia commented 5 years ago

BTW, just a status update on this. @barneycarroll and I discussed this in depth in Gitter (in a private admin-only channel) and we came to the conclusion that we'd like to look into alternate design patterns and abstractions that avoid the need to have context in core. Here's some of these ways to work around it:

Here's some code samples detailing how each of those would work. For the first, passing a model attribute, it's actually quite easy. The snippet below is for something more traditional MVC, but it's easily adapted to something targeting Redux. ```js // Definition class UserModel { constructor(request) { this.request = request this.list = [] } loadList() { return this.request({ url: "https://rem-rest-api.herokuapp.com/api/users", withCredentials: true, }) .then(result => { this.list = result.data }) } } const UserList = ({attrs: {model: User}}) => ({ oninit: User.loadList, view: () => m(".user-list", User.list.map(user => m(".user-list-item", user.firstName, " ", user.lastName) )), }) // Usage const User = new UserModel(m.request) m.mount(document.body, { view: () => m(UserList, {model: User}), }) ``` For the second, it's a little more complicated, but it's still doable. The general shape looks like this, although there are obvious optimizations that could be made for some specialized cases (like in the theoretical router): ```js // Definition const ComponentWithContext = { // Lifecycle crap... view(vnode) { const Foo = { // A component closing over certain `vnode.attrs` and `vnode.state` state } return vnode.attrs.children({Foo, ...otherRelevantData}) } } // Usage m(ComponentWithContext, {view: context => m("div", [ m(context.Foo, ...), m(SubComponent, {context}), context.currentWhatever, ]) }) ```

And in general, it's better and more idiomatic in Mithril to keep context thin and easily traced, and we prefer explicit over implicit - we don't like hiding the cost of things. In addition, not all components need to know how routing works or have access to the global model state. So it really keeps abstraction boundaries much stronger, and doing this kind of thing has other benefits, too.

Despite all this, we're still not against this feature, just we want to first verify there really does exist problems solved by context that couldn't be easily solved without excessive boilerplate in userland through the use of functional programming, dependency injection, higher order component composition, and other design patterns and methodologies. And as it stands, we've yet to see any problem that is legitimately harder or significantly more tedious to solve.

barneycarroll commented 5 years ago

I'm against this feature, but I implemented it anyway. The surface isn't quite the same as React's API, and notably it doesn't change the vnode per se, but it should fulfill all the same requirements. For the sake of unique references as keys, consider Symbols.

This is a composite component pattern and doesn't require extending Mithril in any way.

Here's a demo.

barneycarroll commented 5 years ago

For those who are interested, I've written more extensively about how and why to avoid the context pattern, and understanding context as an API artifact of the kind Mithril shouldn't seek to emulate (same thread).

barneycarroll commented 3 years ago

I’ve got a partial implementation of context in Mithril Machine Tools.

I’m closing this long inactive thread for the purpose of cutting down the backlog but conversation is still welcome.