-
-
Notifications
You must be signed in to change notification settings - Fork 421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Component encapsulation by default #259
Comments
Thanks @theefer for this type of criticism. Cycle.js has evolved quite a lot in the past, thanks to criticism in the same style you are doing now. This isn't the first time, and hopefully from this discussion we can discover an improvement. Out of all of the parts in Cycle, the one which is least elegant is indeed isolation. So to begin with, I agree with you. However the API that we have right now is the best that we have found so far. Or in other words, the least worst. Every once in a while someone mentions that isolation is not nice, but no suggestion of improvement is given either. Before I address each of the points you lifted, let me help clarifying what are the design goals in Cycle:
The implementation of framework code doesn't need to be elegant, application code needs to, is what I believe. The
Classnames in the DOM are not so harmful, but indeed #226 would be a better solution, we'll build that eventually.
The problem is how to achieve isolation by default without introducing magic. The closest we've got to that was introducing some helper function
Once you explain to them that there is no magic going on, they will conclude that
If you look at a child component,
There is one case I know of: http://staltz.com/adapting-controlled-and-uncontrolled-fields.html check the
I unfortunately don't have such benchmark, not until I'm engaged in building a really large Cycle.js. But on the performance story, that is what me and @TylorS are working on right now: we are merging the two projects Cycle.js and Motorcycle.js, where the latter was a super fast version of Cycle.js. We are for instance migrating the DOM Driver to Snabbdom (check performance here http://vdom-benchmark.github.io/vdom-benchmark/, it's ~8x faster than e.g. React). And we are migrating from RxJS v4 to v5 (~5x faster) and also most.js (~300x faster). On your cycle-isolation repo, thanks for the initiative to experiment. This approach: function component(sources) {
const listener = sources.DOM.createListener();
const name$ = listener.events$.map(ev => ev.target.value).startWith('');
return {
DOM: name$.map(name => {
return h('div', [
h('h2', `hello ${name}`),
// Makes explicit what event is being listened to in
// the returned virtual-dom, which avoids having to
// CSS-select on common class names
h('input', {type: 'text', input: listener})
]);
})
};
} is what we explicitly have gone against since always, and there are strong reasons for it: it undermines the whole dataflow foundation. Read effects are supposed to be sources, and write effects as sinks. With that, you have read effects (the I've seen this complaint multiple times and I'm quite sure it will never happen in Cycle.js because it really is not Cycle.js. If people want that, they are looking for Yolk.js. And Yolk.js is pretty neat too, you can use it if you want. Cycle.js is different, it has different goals. Usually the complaint is related to "using CSS selectors is not nice", but whenever I try to dig into the reasons behind that, it's just a matter of taste. In either approach, with or without CSS selectors, anyway you need to repeat the same name in two places. Consider this: function Counter ({props, children, createEventHandler}) {
+ const handlePlus = createEventHandler()
const plusOne$ = handlePlus.map(() => 1)
const count$ = plusOne$.scan((x, y) => x + y, 0).startWith(0)
const title$ = props.title.map(title => `Awesome ${title}`)
return (
<div>
<h1>{title$}</h1>
<div>
+ <button onClick={handlePlus}>+</button>
</div>
<div>
<span>Count: {count$}</span>
</div>
{children}
</div>
)
} Notice function Counter(sources) {
+ const plusOne$ = sources.DOM.select('.handlePlus').events('click').map(() => 1)
const count$ = plusOne$.scan((x, y) => x + y, 0).startWith(0)
const vdom$ = count$.map(count =>
div([
+ button('.handlePlus', '+'),
span('Count: ' + count),
])
)
return {
DOM: vdom$
}
} In practice, it does not affect developer productivity nor code elegance. With all that said, I'm interested in your ideas, I'm not trying to silence this discussion. How can we make isolation work by default without adding framework magic? If we can't do that, can we at least make the isolation concept more obvious and approachable? And how? PS: this is a discussion issue, and our policy is to close all discussion issues. If we discover a concrete improvement suggestion, this issue would become a real issue, and reopened. |
Personally for me I've managed to make reasonably sized apps without using isolation. So isolated by default isn't one of my requirements. But saying that I have stayed away from using isolation thus far so I do wonder if I'm putting it off because I still feel it might not be quite right. I know that's not overly constructive. But with design works well for me right now and with perf improvements then things look very interesting to me. |
Just wanted to make a quick point re:
A framework is just an abstraction to eliminate repetition and promote consistency. From my own work so far, it seems that the two goals above work against each other. If you draw a distinction between "framework" and abstractions refactored into helpers, then sure, but at the end of the day as you build up your library of abstractions, thus promoting consistency and opinion within your application, you end up in defacto "framework" territory anyway. |
Good point, I've thought it a few times as well. On the other hand, |
No, and that's kind of my point. Isolate is a form of "magic"; the kind that falls into framework/helpers territory, and it eliminates verbosity explicitly, because that's what it's supposed to do. I don't think magic is really a bad thing, when it's not too complex to understand and is either doing something very important that needs to be done all over the place, or is optional and can be replaced or ignored easily. I'm writing an isomorphic boilerplate for Motorcycle and have found that it's pretty much impossible not to write a few important abstractions to keep code concise and easy to read. In the process, a bit of magic and opinion is pretty much unavoidable. |
Here is another question for reflection: can we define what is "magic"? Is it "some function I call which does stuff I don't understand but makes things work for me"? Or is it "invisible operations/transformations that happens without me asking for them"? Or is it both? Or something else? |
I'd say a bit of both. Perhaps an important distinction is that when in framework territory, you don't really have the freedom to mess about with how it works without running the risk of breaking the system as a whole, to say nothing of the fact that frameworks are usually pulled in as external dependencies, making them even more of a black box. In contrast, if you are simply using a library of helpers, the full source code of which is internal to the application, then it's less "magic" and more "convenience/opinion". I guess it's just a case of assessing how leaky the abstractions are. Often, the more a framework tries to do, the more ways it finds to get in your way any time you try to deviate from standard use cases. The question is then; how much convenience can you provide without limiting a developer's freedom to sidestep the abstractions when they need to do so, and without making doing so more trouble than it's worth? |
I think that's a great principle to promote, and it is true that a user could easily guess that drivers are global by looking at how they are passed down components recursively. However for me, a corollary of this is the principle of least surprise. The absence of magic tends to help reduce surprises and unforeseen behaviour. That said, I would be surprised if I broke component A because I've refactored completely unrelated component B, inadvertently causing a class or request name clash, and I have to manually introduce isolation. It would be nicer not to have to constantly watch out and have to ask the question of whether a component should be isolated or not, especially as you technically would always have to consider the system as a whole to determine whether there's a risk of conflict (is this component ever reused elsewhere? is this URI ever requested elsewhere? etc). In practice, it'd make sense to me that isolate is the default, with an opt-out if the user wants to do something special. It seems we both agree isolation (or lack thereof) is an issue, but as you said, the question is how to solve it without breaking the good properties of Cycle. Keeping the same semantics, one solution would be to encourage components to always be isolated: function Component(impl) {
return (sources) => isolate(impl)(sources);
}
const component = Component(function(sources) {
// ...
}); The main difference being that the component is then defined as being intrinsically isolated, instead of requiring the parent to inspect its internals to decide whether it needs to be isolated or not. It adds a bit of extra syntax when defining a component, but reduces the boilerplate every time the component is used. It's also extremely simple and not magical.
In your first example, the variable name is repeated. In your second example, a string is repeated. The first one is JS syntax, which will cause a One way to lift the semantics into the JS syntax would be to use a variable again: function Counter(sources) {
+ const handlePlus = '.handlePlus';
+ const plusOne$ = sources.DOM.select(handlePlus).events('click').map(() => 1)
const count$ = plusOne$.scan((x, y) => x + y, 0).startWith(0)
const vdom$ = count$.map(count =>
div([
+ button(handlePlus, '+'),
span('Count: ' + count),
])
)
return {
DOM: vdom$
}
} But at that point it feels a bit silly to be using CSS as a reference across sinks and sources. My main issue with using CSS for this is that it introduces an unnecessary overlap between classes used for styling and thosed used as JS hooks. It's using the same namespace for two completely different purposes and refactoring scopes. In the past, when writing Selenium tests, we used class names prefixed with function Counter(sources) {
+ const handlePlus = sources.DOM.createMarker();
+ const plusOne$ = sources.DOM.select(handlePlus).events('click').map(() => 1)
const count$ = plusOne$.scan((x, y) => x + y, 0).startWith(0)
const vdom$ = count$.map(count =>
div([
+ button('+', {ref: handlePlus}),
span('Count: ' + count),
])
)
return {
DOM: vdom$
}
} I wonder if this would also give an opportunity to make the filtering more efficient, by being able to filter events for all listeners in O(1) (ref lookup on the |
Are sensible defaults, or conventions, considered magic? I've recently started tinkering with a repo for Cycle UI components. One of my goals is to reduce boilerplate to show the intent of the code. My thoughts were to introduce a property (I plan of having TypeScript interfaces for properties) on the Cycle-UI components to allow explicit setting of isolation, but have a sensible default. Isolate being true sounds like a sensible default to me. This can be set explicitly (true) for those who prefer an explicit style. Rob Eisenberg's work on the Caliburn Framework I found interesting in regards to conventions.
I haven't followed them recently, but previously I have seen some comparisons between Angular 2 and Rob's Aurelia framework. Angular 2 seemed to want you to declare everything, but ended up with a lot of noisy boilerplate that took away from the purpose of the code. |
@theefer Good suggestions overall. function Component(impl) {
return (sources) => isolate(impl)(sources);
}
const component = Component(function(sources) {
// ...
}); Notice that instead you could have just written const Component = isolate;
const component = Component(function(sources) {
// ...
}); or just const component = isolate(function(sources) {
// ...
}); Maybe now it's a bit more obvious that About the CSS selectors: Usually I use CSS classnames without In other occasions, I use inline styles. And in other occasions I don't really experience the problem of using the same classname for both styling and JavaScript. But I see your point. I like the |
And @Frikki and @TylorS and @laszlokorte I'd really appreciate if you have opinions on this. |
Tossing a few other related ideas I just had:
|
Some observations: |
I don't believe this works (I did try before posting my suggestion), and the reason is that isolate returns an isolated component (by a random name if not provided), so here
That's true, That said The only way I can think of to keep something like this pure is to take the "ref generator" as input and return it as output, throughout the application. Something like: function component(sources) {
const gen1 = sources.Gen;
const [r1, gen2] = gen1.next();
const [r2, gen3] = gen2.next();
// ...
return {
// ...
Gen: gen3
}
} That's obviously awful boilerplate, and I'm not sure how easy it is to abstract away in JS (I've only seen it done in Scala or Haskell). I did think of Can a These are just a few thoughts, I am off for a week now but definitely interested in pursuing this further and helping out if I can! Thanks for the interesting discussion. |
Yes, that's right. Sorry for the brain error.
Yeah I know, I guess I meant that all isolation solutions so far are not pure. |
I am really not sure why "encapsulation by default" is so important. I think it comes down to the understanding of what a "component" is. Being just a function I really do not see how automatic isolation would be possible at all because if I have a I am not seeing a way for any automatic scoping to happen why calling a function. But in my opinion that is not a problem at all. Once you accept that components are just functions it's pretty clear that components are not isolated by default. If you want isolation you have to to something. Calling Finding unique custom ids for Just because "component" is such a fancy word everyone assumes that isolation has to happen automagically. The same way you have to call I really like separating the isolation from the component definition itself. I like that a |
Me too, and as far as I understood from Sébastien, it's not about making it actually automatic, but just more obvious while creating the component function, either it's automatically isolated (with the helper This issue might be a symptom of bad docs too, if people didn't understand this from the current docs. On the other hand, people may have expectations of what a "component" means, based on previous experience with other tools, and they may be attempting to mentally fit an old concept into Cycle's concepts. Maybe we could name things differently, maybe drop "component" altogether. Because as far as I've noticed, in web development, component means some name wrapped with angle brackets. |
If you create two input elements in HTML, aren't they isolated by default? |
Yes they are, but Cycle.js components are nothing like HTML elements. |
@arlair no they are not. you would to to give them ids or classes to separate them later. If you just to |
Aren't they both used to make a visual widget? @laszlokorte but if i start typing in the first input, it is not mirrored in the second input. Their state is isolated. I guess I need to read up again on how isolate works in Cycle.js :) |
@arlair ok then I misunderstood your question. But then let me ask: How are you creating simple html elements via javascript? just by calling Cycle: If you are talking about html/xml like syntax I am sure there is a way to configure JSX to support cyclejs components and calling |
@laszlokorte If I take the BMI nested examples and remove the isolate calls, moving the second slider changes the first slider. If I make two input sliders in HTML and move the second slider, it would only change the second slider. I'm not sure I am comparing exactly the same thing and whether this is just an implementation detail, but by default if I create two visual widgets, I don't expect them to be linked by default. Perhaps I am thinking of Cycle Components the wrong way though. I do agree that HTML elements don't have any id or classes by default and had also thought of that. But in some ways HTML elements have isolation by default. I guess the issue I am talking about is coming from the internal intents of the Cycle Component being linked by default. I'm on the HyperScript Helpers train at the moment, so hadn't even considered JSX :) Update and yes, in HyperScript Helpers I would call div(). |
@arlair I am aware of what happens when you remove the isolate calls. What I am saying is: Calling Calling
|
First to say: Cycle's idea of cycling/rotating signals from sinks to sources is just brilliant and I like it very much. However, in my opinion, Cycle.js tries to apply this "cycle" to wrong concerns, thus causing such isolation problems. I hope this post is not interpreted as an offense - it's not that. I just want to share you some observations that have been puzzling me. MVI != IMVCycle claims that it's a "model-view-intent" architecture, which is not true. To be exact, Cycle is "intent-model-view". In my opinion the order of M, V and I important when building interactive applications: there can't be view without the model (view is just a projection of the model) and there can't be intent without a view (you can't press a button if it doesn't exist). However your model can't be updated without signals from intent, hence the "cycle". But if you rotate This discontinuity has also funny implications to the codebase that normal reactive applications don't have. How about the code: function main({DOM, HTTP}) {
const fooRes$ = HTTP.filter(isFooReq).switch()
const fooReq$ = DOM.select(".send").events("click").map(asFooReq)
return {
DOM: ...,
HTTP: fooReq$
}
} But wat wait??! How can we have response before request? How should we place the fancy arrows between the request and its response? Maybe Cycle.js is not so "fully reactive" as it claims? Cycle advocates pure How to improve it?I unfortunately see only one way to get rid of the isolation and make Cycle "truly reactive": relax the current goal of having only "pure" main (which it's not even now btw), re-think the role of drivers and make Thus the driver should be a triplet of
"Cycle" is then just a driver In practice e.g. DOM driver could have the following properties:
HTTP driver would be even simpler:
Some codeSignals driver is trivial: function makeSignalsDriver() {
return function signals(signal$) {
return Object.assign(signal$, {
out: output => signals(Observable.merge(
Object.keys(output).map(key => output[key].map(value => ({value, key})))
)),
in: key => signals(signal$.filter(s => s.key === key).map(s => s.value))
})
}
} DOM driver is a little bit more complicated, but here is a naive example implementation with With those we can compare the difference of function main({DOM}) {
const intent = ({DOM}) => ({
inc$: DOM.select('.decrement').events('click').map(ev => -1),
dec$: DOM.select('.increment').events('click').map(ev => +1)
})
const model = ({inc$, dec$}) =>
inc$.merge(dec$).startWith(0).scan((s, a) => s + a).share()
const view = (counter$) => ({
DOM: counter$.map(count =>
h('div', [
h('button.decrement', 'Decrement'),
h('button.increment', 'Increment'),
h('p', 'Counter: ' + count)
])),
value$: counter$
})
return view(model(intent({DOM})))
}
run(main, {
DOM: makeDOMDriver('#main-container')
}); And then function main({signals, DOM: {render, h, events}}) {
const model = (signals) =>
signals.in("inc").merge(signals.in("dec")).startWith(0).scan((s, a) => s + a)
const view = (counter$) =>
[counter$, render(counter$.map(count =>
h("div", [
h("button.increment", "Increment"),
h("button.decrement", "Decrement"),
h("p", "Counter: " + count)
])))]
const intent = ([value$, dom$]) => ({
DOM: dom$,
signals: signals.out({
inc: events(dom$, ".increment", "click").map(_ => +1),
dec: events(dom$, ".decrement", "click").map(_ => -1)
}),
value$
})
return intent(view(model(signals)))
}
run(main, {
DOM: makeDOMDriver('#main-container'),
signals: makeSignalsDriver()
}) How about the isolation? No difference - just give input signals to the child component function main({signals, DOM}) {
const {render, h} = DOM
const a = Counter({signals: signals.in("a"), DOM})
const b = Counter({signals: signals.in("b"), DOM})
const vdom$ = Observable
.combineLatest(a.DOM, b.DOM, a.value$, b.value$, (A, B, a, b) =>
h("div", [
A, h("hr"), B, h("hr"),
"Total: " + (a + b)
]))
return {
DOM: render(vdom$),
signals: signals.out({
a: a.signals,
b: b.signals
})
}
} Same applies to dynamic content / lists: https://gist.github.com/milankinen/a990cd845f1c9670ee1e |
@milankinen I do not see why currently a cycle component is not a pure function. Except for the argument that javascript is not a functional language and the compiler can not prove the pureness. I would argue that my cycle components are pure functions.
I think I really do not get the point of what you are saying. Especially because of the functional architecture it does not matter if you declare the response stream before the request stream because in some sense the order of execution gets less important. You write the application in a declarative way. You can swap I see that you have put some thought into your concept but at a first glance it just looks to complicated and over engineered. Could be that I just not get it yet. In your last code snipped you create two counter components:
Then you use I am not sure I understand what problem you like to solve. You say that you are missing the connection from view to intent. I would argue that this connection is not important at all. It's just an implementation detail of how the browsers work that you first need a button to exist in order to handle some click events. Hm... while writing this parts of what you wrote begins to make sense to me 😄 ... Accepting the already interpreted signals/actions/intents as input and returning the view-to-intent transformation along with the view as result... Cycle's IMV still feels more natural and way simpler to me but the more I think about it I understand your point. |
@milankinen Thanks for the new suggested architecture. I love new ideas. I can see what you are trying to achieve, and why: "discontinuity is not nice". But I'd also point out that your M=>V=>I is significantly different to Cycle.js in many ways, that it may not be worth pivoting Cycle.js to migrate to M=>V=>I at this point. It may make sense to build M=>V=>I as a new experimental framework, and see how it matures, independently of Cycle.js. Your suggestion is too radical for the foundational ideas in Cycle.js, which are:
Your suggestion undermines all of these 3 principles, and we can't compromise them. They give properties which people have been enjoying: simple abstraction where there's no doubt where read or write effects should be put, simple testability since you give a fake read effect and check what is the write effect coming out, pure dataflow in main() which can be represented as a dataflow graph. About point (1) you said it's not pure, and that's true only if
This is a misunderstanding, and there really can be an intent without a view, and here's an example: https://github.com/cyclejs/examples/blob/master/animated-letters/src/main.js#L6 Some time ago when Cycle was still in alpha, we used to have the architecture MVUI (Model-View-User-Intent) because the point was that View was given to the (actual) User, which on the other hand would produce effects that Intent captures and interprets. Later on, we ended up hiding the User and other effects in drivers. But this is all because Cycle.js is about representing Human-Computer Interaction in the most natural way. It's really all about this graph: https://upload.wikimedia.org/wikipedia/commons/a/a1/Linux_kernel_INPUT_OUPUT_evdev_gem_USB_framebuffer.svg
Isolation isn't a "good guess from the app" and there are actually no isolation problems. All we are discussing in this issue is the developer ergonomics of
"Fully/truly reactive" is not a vague stamp that I put on the framework, there is a point to it. The opposite of reactive is passive/proactive programming (it's explained in the docs) and it happens when the definition of a property's behavior is spread throughout the code. You see this visible in Flux/Redux where Store/Dispatcher is passive and In Cycle.js, we don't have that, not even with requests and responses. I didn't want to have the problem of "looking for all the usages of X" in the codebase. If you want to know what affects requests to happen, you just look at the definition of To keep the definition of "reactive" solid and technical, think of it as: "once I define/declare foo$, nothing else may change that definition or the relationship between what-affects-what", or as well said here:
And this applies to
As Laszlo pointed out, the order of declarations in a Cycle.js app doesn't (or shouldn't) matter. Take even a simple example, and the same behavior is there as well: https://github.com/cyclejs/examples/blob/master/hello-world/src/main.js#L6 The declarations in any All in all, I like your ideas, I think you should build them and experiment, but it's too radical to be a Cycle.js thing. And thanks for your time reporting it here. :) |
Conceptually it is a read effect. That event listener have to be added/written into the DOM tree is just an implementation detail of the DOM. As I wrote a few days ago in gitter: Now if you want to calculate the sum of 5 and 7 on a typical computer with a simplified architecture, eg a stack machine, you would have to do something like:
Oh no!! all of those instructions cause side effects! The first instruction just modifies the stack!!! and then the second instruction does it again!! And the last instruction even removes the values the previous instruction have put there! If if you try to calculate (5+19-2*91)/2 in your head you will do it in multiple steps ie the state of your mind is changing from step to step. 5 + 19 is 24, 2 * 91 is 182, 24 - 182 is -158, -158 / 2 is -79... Still the addition of two numbers is a pure function. How can this be? It's all about abstraction. Is it important how the stack machine changes the state of stack during the computation? Is it important what steps you do in your mind in order to calculate an expression? Now back to the listeners: Listening for click events has no state either. And you do not have to modify any objects in order to listen for events. You can just sit there and listen. It's just an implementation detail of the DOM that you have to modify a node in order to receive events. The same as modifying a stack is an implementation detail of a cpu to calculate a sum. Now if you intentionally design the API in a way to expose the implementation detail of adding listeners as write effect you are sacrificing a layer of abstraction. It's a step back. How would your And that's exactly what the DOM driver is currently doing: |
The fact that it is lazily attached to the root is implementation detail. If we would have attached it eagerly on the root when the dom driver is created ( so not anymore on the first call of select().events()), it wouldn't make any difference at all for the application developer. The point here is: conceptually this is an entirely harmless side effect and I have no idea why we discussing this as a problem. It is not.
Now we just lost an important property.
Because now we have read effects (click listening) encoded in the sinks. and this is a real problem because the virtual DOM rendering isn't anymore just about rendering a visual output to the user, it is also about programming what happens when the user interacts (read effects). In practice if I want to code the users interaction, I should only write code in the |
Thanks all for the stimulating discussion. Ultimately, my aim with this issue is to improve developer ergonomics while retaining Cycle's architectural philosophy. Hopefully everyone would agree this is a desirable goal, though there seem to be some disagreements on what the ergonomics should be, and what is a reasonable trade-off between framework purity and ergonomics. Stepping back a little, I think there are a number of separate issues being discussed here, so for my own sanity I tried to tease them apart. Drivers are global sources (No isolation by default)One of the key benefits of functional programming is its declarative nature. In a pure function, all the inputs are specified and given the same inputs, referential transparency guarantees the same output. There is no dependency on or mutation of anything outside that scope. I would argue one of the reasons why isolation is so important in Cycle, whether it's an explicit call to Technically, a component would remain a pure function even if it reacted on DOM events or HTTP responses that didn't "belong" to it, because they are still indeed part of the component's inputs. However, there is almost no cases where this is intended or desirable. Typically, in FP, one would provide the smallest set of inputs possible to a function so that it only depends on what it requires. This helps reduce cognitive overload of tracing and managing a function's dependencies. In the case of Cycle, in almost every case, a component is really only interested in the sources that were a result of its sinks. In other words, the developer typically wants the components sources to be encapsulated, and that's where There may be cases where One simple solution would be to update the docs to recommend using For this to work, we also need to be confident that (See also #268) Ergonomics of "closing the loop"As @milankinen pointed out, a lot of the challenges discussed here stem from the way Cycle "closes the reactive loop". That "discontinuity" is filled by the drivers that connect sinks and sources to feed the components' output back as their input. However in JS, there is obviously no concept of reference passed from the value returned by a function into its arguments, which means it's the developer's responsibility to match them up. This is were a few more issues are debated:
Other concernsA few other questions are raised but not as critical, or more subjective/contentious, so probably best ignored in this thread:
In my personal opinion, the most important change to improve developer ergonomics would be steps toward isolation by default, either by making |
👍 (except the naming, I like isolate as it's pretty concise)
just isolate the component. As you suggested calling
Performance is almost always just an implementation detail. Avoid premature optimization. You should not change an architecture based on a wild guess.
As i wrote earlier you do not have to use class names. Just do
As you said yourself this would require a huge change in the architecture. Cycle itself is only about 100 lines of code. There is not much to rethink without building just a completely new framework.
Please see my previous answer.
I have always understood it as a best practice. That's why I have not yet understood the problem "everybody" seems to have. Just call |
This was written in the second comment of this thread. Why in this case is it not desirable and suggesting we aren't just good to go? |
@arlair I am not sure if I understand your question correctly. In that case the parent of the
What do you mean by that? |
It seems this comment, "Just call isolate everywhere and you are good to go" is opposing the quote above saying it is not always desirable. |
@arlair Well if you have a special case in which you do not want isolation you should not call it 😄 |
😬 Incorrect
😬 Incorrect There's some deep misunderstanding of fundamental concepts here about Cycle. First, drivers are not "global sources". Drivers are functions, every single one of them. As a function, they take in an Observable of ALL the write effects that may happen for that particular concern in the app, and return ALL the read effects for that concern. Replace " concern" with HTTP and it'll make sense. The HTTP driver is a function that takes ALL requests that may happen in the app and returns ALL responses that the app can possibly read. Isolation is simply the routing of those effects from parent to its children. Think of a parent component with two children: Dog and Cat. The Dog component sends out sinks for requests to get dog gifs, and the Cat component sends out sinks for requests to get cat gifs. The parent component blends those two different types together to get an Observable of both cat and dog gif requests. The parent also processes/filters/routes the read effects (sources). The sources.HTTP contains responses for both cat gifs and dog gifs. It then needs to pass on ONLY dog gif responses to the Dog component and ONLY cat gif responses to the Cat component. This is "isolation". The parent can either do that manually by actually fiddling with the response details, OR the two children components may automatically assign a unique id to their requests, and then when receiving their sources, they automatically know how to filter out only those responses that correspond to the request that they created. This is And notice at this point, the parent itself may be isolating the requests coming into it as sources. Maybe the parent has a grandparent, and the parent has some siblings. Meaning that sources.HTTP given to the parent may not contain all possible responses from the app. Maybe the whole app also gets responses for giraffe gifs, but the parent only gets dog and cat gifs. As you see, sources are not global. They may very well be scoped, and may contain read effects that require routing to its children in that scope.
😬 Very incorrect. Drivers (functions!) don't even contain the application state. Read and write effects are not global state. Also nothing is injected into any component. Injection is an intrusive and ad-hoc input passing. A function argument is just that: a function argument. I may pass it whatever I want. A component deep inside a hierarchy may get sources.HTTP containing all responses globally, or may receive a sources.HTTP that only has cat gifs. It's just a function input and not much is assumed of what exactly does it contain.
Incorrect because of my paragraph above. Sources are not drivers. Sinks are not drivers. Drivers are functions. Sources are things representing read effects from the external world. Sinks are things representing write effects for the external world.
This is the part where I agree with you. Honestly I thought it was obvious from the documentation already, because it starts by explaining I'd like to point out also how scoping and ownership of the effects is something which exists in all of the unidirectional architectures I have seen so far. I was just today talking with colleagues about problems in the Elm architecture (who are using Elm right now) related to just that: how to define ownership of an action among component siblings. In one way or the other, in each of these architectures you need to be aware of scopes. Cycle isn't different. |
OK I used the wrong terminology in a few places, but I think you understand what I mean and you're being a little pedantic. 😄 Let me try again: sources, returned by drivers, expose the global set of read-effects (e.g. responses, access to DOM node/events) to components, sometimes accessed by calling a function of the sources (e.g. "Application state" was not the best phrase, as it more often refers to the Model, and in Cycle it is not exposed by the sources, but managed internally to the component that owns that piece of state. My point was that without isolation, which is the default, each component is both responsible and trusted to only access its own read-effects from the set of all read-effects of the app, and that is akin to exposing much more state (in the logical sense) than necessary or desired in a traditional FP context.
Reading the docs again, the isolation practice is well described in Should I always call isolate() manually?. It's quite far down an in-depth explanation though, and looking at the cycle/examples repo, very few of the examples use isolate (only those that strictly require it), and all of these examples call If you agree that practice would be worth recommending as best practice by default, maybe demonstrating it more widely would be enough for people to pick it up as a habit? |
Good point. In general those examples are quite small, so each one can be considered a component in itself, hence no need for isolation in those examples. But anyway, we need larger examples where multiple components are used and That's a good action point, I'll make an issue out of it. #269 |
@laszlokorte No but that's because I explained it very unclearly. 😄 What I meant is that because there is no real connection between In contrast, HTTP driver does this better - for every sink signal, there is one source signal. And that's why HTTP driver is easier to isolate "purely" (i.e. without CSS class tricks). |
@milankinen Ok I now I got what you are saying.
The same way that a component is not able to select child dom elements it should not be able to process child http requests. |
That is actually by design, not a mistake. I remember we discussed the motivation somewhere, I need to dig up where was it. |
@laszlokorte Actually it's the opposite: if the child component exposes its sink signals (requests), then the parent must have also access to child's source signals (responses). However, the framework should ensure that these signals can't get mixed by accident. |
That's why in TSERS it's all about signals and composition. "Isolation" is just the parent component deciding which output (sink) signals may go through and which should go back to child's input (source) signals. It's literally running application inside your application. |
Yes you are right. The parent must somehow have access to the child's sources in order to provide them to the child. But by default when subscribing/transforming the HTTP response stream I would assume that the parent would not see the child's responses because they are in a different scope. regarding TSERS: As I already wrote in gitter I appreciate your effort. Especially the composition of components looks quite elegant. |
This is where the DOM Driver and HTTP Driver differ. In reality they don't differ, because DOM Driver has |
Note that in the solution discussed above where sinks and sources are matched with a marker (or symbol, generated ID, etc) internal to each component, be it a DOM node, DOM event or HTTP request/response, only the component itself (or any other component it exposes the reference to) can retrieve the corresponding data from the sources, even though the sources may contain state from all components throughout the app. In other words, parents don't have the risk of inadvertently intercepting a child's HTTP response. I'm still not sure it's necessarily the right improvement, but it does incidentally solve the problem. |
it just the idea behind current |
Not quite, especially in the case of HTTP, as per the case describe above of parent/child exposure? |
Ok I see you want parent be isolated from childs things, but I'm not actually sure that is aways desirable, sometime you want parent know what is children are doing. But I'm sure that it can be implemented in driver. HTTP (an other a like drivers) could implement something like |
This issue relates to possible isolation mechanics of HTTP driver |
Yes, I agree on that, and I remember it being one of the motivations for keeping it like it is. Isolation is primarily between siblings. The parent technically should have control and visibility over everything that comes in and goes out. |
@milankinen interesting points about MVI vs IMV. Actually I think there are more options than just rotating this triad. MVI concept is drastically oversimplified to begin with. There are "actions" (intents combined with state like signal gates). So it's getting closer to: One more possibility is: This one addresses complaints about state being not in the tip in the graph (which complicates reasoning about the dataflow). But still is perfectly compatible with actual CycleJS core. We can call this I-M-V or M-I-V with equal rights because I made apps with both and both worked reasonably fine 😄 |
@ivan-kleshnin Do you have the app sources available somewhere? I'd be interested in taking a look at them. |
Yes. First diagram: https://github.com/ivan-kleshnin/cyclejs-examples/tree/master/3.0-crud/src I've been experimenting with different CycleJS dataflows for last two months. |
My main issue with the current Cycle architecture is the way drivers are actually globally scoped:
DOM.select('.field')
looks for an element in the whole DOM subtree managed by CycleHTTP
is the stream of all HTTP requests/responses going through the driverThe developer has to be aware of this and take extra precautions to filter out other inputs, such as other parts of the DOM also matching the selector, or other requests issued by other bits of the app.
The HTTP and fetch drivers encourage filtering the stream of requests by URL or name, but that solution is suboptimal because it could easily suffer from clashes if other components requested the same URL or used the same identifying name. Put otherwise, there is no strong guarantee of isolation.
The better, driver-agnostic solution to this problem is isolate (provided the driver implements
isolateSource
/isolateSink
-- the fetch-driver doesn't for example).It could be argued the implementation of
isolateSource
/isolateSink
is somewhat inelegant compared to the rest of Cycle, with the HTTP driver patching a_namespace
property on the request, and the DOM driver writing class names that end up in the DOM (see #226), but I guess these are mostly hidden away from users.However, shouldn't isolation be default behaviour, possibly with an opt-out, rather than being opt-in?
Anecdotally, of all the people I talked to this about, no-one had realised that
sources.DOM.select(_)
was not scoped to the virtual-dom subtree returned by that component.I really love the concept of fractal architecture @staltz is striving for, but I would argue one of the key benefits is to reduce cognitive load and complexity for the developer by allowing local reasoning, without side-effects. Without isolation, there's a persistent risk of global "side-effect" (in the broad, non-FP sense) and hard-to-debug collisions between distinct parts of the system.
Another way to phrase this is: when is isolation not desirable? Surely, components should always be isolated, unless they rely on side-effects (watching DOM they don't own, catching requests they didn't issue), which is antithetical to the whole concept.
These practical concerns aside, I'm also curious about the performance and scalability implications of having such centralised management of streams. Are there benchmarks of how this scales up to thousands of components, with hundreds of subscribers to the HTTP driver requests stream and thousands to the DOM driver events streams, recursively calling the
isolateSource
andmatchesSelector
for each level of the component tree?I'd like to make it clear that I still think Cycle is a fantastic idea, and the reason I raise this is because I'm worried this could get in the way of its success.
To make this a more constructive criticism, I've also played with an alternative DOM driver API that keeps effects local. I've put it up in a repo on GitHub, in the hope it can spur discussions on alternative solutions to this issue.
I admit I was also trying to make observed events more explicit in the component sinks, rather than relying on
.events()
on the sources and manually matching class names in the.select(_)
and outputs. However I'm interested to hear what people think, what principle I have violated doing so, and what may be wrong with this approach 😄/cc @jamesgorrie @kenoir @phamann @OliverJAsh @jackfranklin who I discussed this with in the past few weeks.
The text was updated successfully, but these errors were encountered: