Skip to content
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

Closed
theefer opened this issue Mar 2, 2016 · 89 comments
Closed

Component encapsulation by default #259

theefer opened this issue Mar 2, 2016 · 89 comments

Comments

@theefer
Copy link

theefer commented Mar 2, 2016

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 Cycle
  • HTTP is the stream of all HTTP requests/responses going through the driver

The 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 and matchesSelector 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.

@staltz
Copy link
Member

staltz commented Mar 2, 2016

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:

  • No Framework magic: Everything that happens is explicit from the code that I read
  • Defeat verbosity: with functional architecture and powerful operators from RxJS
  • Reads come from sources, Writes go to sinks, always.

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

The implementation of framework code doesn't need to be elegant, application code needs to, is what I believe. The _namespace property is quite clear it should be private, but lately I've been advocating the use of category property: https://github.com/cyclejs/examples/blob/master/http-random-user/src/main.js#L11

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.

Classnames in the DOM are not so harmful, but indeed #226 would be a better solution, we'll build that eventually.

However, shouldn't isolation be default behaviour, possibly with an opt-out, rather than being opt-in?

The problem is how to achieve isolation by default without introducing magic. The closest we've got to that was introducing some helper function useThisComponentNow(comp), but that's still the same as isolate: you need to remember to call it. At this point, remember the "everything is explicit, no magic anywhere" design goal.

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.

Once you explain to them that there is no magic going on, they will conclude that DOM.select() is global.

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.

If you look at a child component, sources.DOM.select() actually does not tell whether that is locally-scoped or globally-scoped. And that's ok, because sources is given as an argument to the child component.

Another way to phrase this is: when is isolation not desirable?

There is one case I know of: http://staltz.com/adapting-controlled-and-uncontrolled-fields.html check the Input component

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 and matchesSelector for each level of the component tree?

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 input event, you want to read values from the user) are encoded in the sink. It's not enough to put listener in sources.DOM.createListener(), because the DOMSource is a collection of Observables, and createListener is not an Observable, it's a factory that returns new RxJS Subjects. Besides being an impure function (it creates a new Subject every time you call it), it really is not a dataflow source, it's just a generic Subject for any purposes.

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 handlePlus written twice. Now notice .handlePlus written twice here:

 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.
To focus the conversation, here's what I propose: how can we answer the following question?

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.

@staltz staltz closed this as completed Mar 2, 2016
@Cmdv
Copy link
Contributor

Cmdv commented Mar 2, 2016

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.

@axefrog
Copy link

axefrog commented Mar 3, 2016

Just wanted to make a quick point re:

  • No Framework magic: Everything that happens is explicit from the code that I read
  • Defeat verbosity: with functional architecture and powerful operators from RxJS

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.

@staltz
Copy link
Member

staltz commented Mar 3, 2016

Good point, I've thought it a few times as well.

On the other hand, isolate doesn't add (excessive) verbosity.

@axefrog
Copy link

axefrog commented Mar 4, 2016

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.

@staltz
Copy link
Member

staltz commented Mar 4, 2016

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?

@axefrog
Copy link

axefrog commented Mar 4, 2016

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?

@theefer
Copy link
Author

theefer commented Mar 4, 2016

No Framework magic: Everything that happens is explicit from the code that I read

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 either approach, with or without CSS selectors, anyway you need to repeat the same name in two places.

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 ReferenceError in case of mismatch. The second is purely application semantics that cannot be enforced by statical analysis.

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 js-to denote classes used as JS/programmatic hooks, as opposed to styling hooks. We could do the same here, but then why not use another marker on the node (as per #226)? At which point it doesn't have to be a user-readable string, but can be a unique ID/ref instead, which would solve any conflicts and provide de-facto "isolation":

 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 event.target) instead of recursive matchesSelector calls?

@arlair
Copy link
Contributor

arlair commented Mar 5, 2016

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.

One of the main features of Caliburn.Micro is manifest in its ability to remove the need for boiler plate code by acting on a series of conventions. Some people love conventions and some hate them. That’s why CM’s conventions are fully customizable and can even be turned off completely if not desired.

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.

@staltz
Copy link
Member

staltz commented Mar 5, 2016

@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 isolate is already everything we need. Maybe you just wanted it to be renamed to component? I can see how it may help for beginners, but I'm divided: the name component explains what you should use it for but doesn't explain what it does, but the name isolate explains what it does without explaining what you should use it for. componentWithIsolatedSourcesAndSinks would be the correct name, but too verbose.


About the CSS selectors:

Usually I use CSS classnames without js- prefix only for Cycle.js, and actual styling classnames are hashes as names, coming from a library like free-style. So I don't get that conflict.

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 createMarker(); suggestion, specially for doing quick lookups for event listeners. On the other hand it worries me that it's not a pure function. In general I'm worried that isolation solutions are impure, so far.

@staltz
Copy link
Member

staltz commented Mar 5, 2016

And @Frikki and @TylorS and @laszlokorte I'd really appreciate if you have opinions on this.

@staltz
Copy link
Member

staltz commented Mar 5, 2016

Tossing a few other related ideas I just had:

  • Use Symbols for refs, the Symbol would be created internally in the component and this would be pure
  • Cycle DOM's isolateSink and isolateSource would do pre/post-processing in order to convert strings to refs, or convert refs/Symbols to strings

@staltz
Copy link
Member

staltz commented Mar 5, 2016

Some observations: Symbol() is not referentially transparent, but could potentially solve isolation issues everywhere, including in other effects like HTTP. At this point I'm trying to think of what would be the real problems of impure solutions for isolate. Eventually, I'd like to move Cycle.js away from JS and into some functional programming language, maybe Elm maybe PureScript, and any impure part would make it hard to port.

@theefer
Copy link
Author

theefer commented Mar 5, 2016

Notice that instead you could have just written

const Component = isolate;

const component = Component(function(sources) {
  // ...
});

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 component is isolated, but using that component multiple times will reuse the same isolation name, hence breaking the isolation.

In general I'm worried that isolation solutions are impure, so far.

That's true, createMarker would not be a pure function. Having that helper injected as part of the sources probably makes it easier to test and mock.

That said isolate is also not a pure function, unless you provide a name explicitly, which defeats the point somewhat (reintroduces the risk of conflict).

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 Symbol(), though as you said it's not referentially transparent. createMarker() could return a Symbol, which keeps the side-effect in a helper provided as input (and mockable, etc).

Can a Symbol be set as a property of a DOM node? I guess probably yes.

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.

@staltz
Copy link
Member

staltz commented Mar 5, 2016

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 component is isolated, but using that component multiple times will reuse the same isolation name, hence breaking the isolation.

Yes, that's right. Sorry for the brain error.

That said isolate is also not a pure function

Yeah I know, I guess I meant that all isolation solutions so far are not pure.

@laszlokorte
Copy link
Contributor

And @Frikki and @TylorS and @laszlokorte I'd really appreciate if you have opinions on this.

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.
Yes a component should not interfere with neighbor components so it should be isolation in some way.
On the other hand it's emphasized that any cycle application can be used as component in another application and that a cycle application is "just a function" - therefor every component is "just a function".

Being just a function I really do not see how automatic isolation would be possible at all because if I have a DOM object and I pass it into a function (into a component) I really expect the function receives to be exactly the same as what I passed in and if I return a value from a function I expect the caller to get exactly that value.

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 isolate(MyComponent)(sources) instead of MyComponent(sources) is perfectly fine because it expresses that MyComponent is not just called as function but is isolated. Typing isolation is not anymore effort than typing new for object creation in most OOP languages. Especially since you can alias isolate as i or Component or create or whatever you like.

Finding unique custom ids for isolate()'s second parameter is not a problem since they have only to be unique among siblings not across the whole application.

Just because "component" is such a fancy word everyone assumes that isolation has to happen automagically.

The same way you have to call Cycle.run(yourApp, driver) instead of yourApp(drivers) because a simple function call would not wire up the sources and sinks a cyclic way you have to call isolate(MyComponent) instead of MyComponent because a simple function call could not to any isolation.

I really like separating the isolation from the component definition itself. I like that a component is just a function because a function is a concept that is already understood very well and comes with the language.
Coming of with a fancy automatic scoped component just introduces a new concept that is specific to cycle and would couple all component definitions to CycleJS.

@staltz
Copy link
Member

staltz commented Mar 5, 2016

I like that a component is just a function because a function is a concept that is already understood very well and comes with the language.

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 component = sources => isolate(myComp)(sources)) or isolating it when calling it.

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. foo may be anything, but <foo>, "oh that's a component".

@arlair
Copy link
Contributor

arlair commented Mar 5, 2016

If you create two input elements in HTML, aren't they isolated by default?

@staltz
Copy link
Member

staltz commented Mar 5, 2016

Yes they are, but Cycle.js components are nothing like HTML elements.

@laszlokorte
Copy link
Contributor

@arlair no they are not. you would to to give them ids or classes to separate them later. If you just to document.getElementsByTagName('input') you would get both. if you do document.addEventListner('input') you would get both their events.

@arlair
Copy link
Contributor

arlair commented Mar 5, 2016

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 :)

@laszlokorte
Copy link
Contributor

@arlair ok then I misunderstood your question.

But then let me ask: How are you creating simple html elements via javascript? just by calling div()? No... you call document.createElement('div') so you could see the createElement function to be similar to isolate:

Cycle: isolate(myInput)
Js: createElement('input')

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 isolate on them automatically.

@arlair
Copy link
Contributor

arlair commented Mar 5, 2016

@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().

@laszlokorte
Copy link
Contributor

@arlair I am aware of what happens when you remove the isolate calls. What I am saying is:
Just do not remove them.

Calling Slider(sources) instead of isolate(Slider)(sources) is just the wrong way to create a slider the same way that new alert("foo") would be just the wrong way to call alert("foo").

Calling Slider(sources) is wrong because Slider is a function and Slider() calls that function. Which is not what you want. You want the Slider to be isolated so you should call isolate(Slider)(). Simple as that.

var myEl = document.createElement('div') will create a div. If you just remove the document.createElement it wont work: var myEl = 'div' will not create an element.

@milankinen
Copy link

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 != IMV

Cycle 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 IMV two steps, you'll get MVI, right? Yes, in the pure world. The "cycle" causes discontinuity to signals. Because the intent is before the model-view in Cycle, signals from the view must come through this discontinuity via drivers. Because drivers can't guarantee a -> a, causation of view -> intent signal is lost. The application can only make good guesses (=isolation) which input signals relate to which output signals. "Truly reactive" apps do not have such problems - they are just signal transducers taking signals in and outputting transformed signals.

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 main tries to put all side-effects to drivers. That is very admirable goal, I agree with that. But as soon as driver starts doing side-effects, it can't guarantee a -> a. That combined with discontinuity is too much to handle. It's like trying to solve an equation of two variables with only one form.

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 main just a signal transducer that might also produce signals encapsulating side-effect.

Thus the driver should be a triplet of {-> a, [(a -> b)], b ->} where

  • -> a is the signal a producer (= source), if driver produces any signals
  • [(a -> b)] is a set of (stream) transformations that encode side-effects into result signals b <-- this is missing ATM
  • b -> is just a sink of signals b which actually decodes the side-effects

"Cycle" is then just a driver {-> a, ∅, a ->} which guarantees a -> a (when signal a goes to sink, it'll (eventually) appear as it is from the source). And this guarantee is extremely important so that we can map output signals back to input signals and reason our application properly (and to start following MVI instead of IMV).

In practice e.g. DOM driver could have the following properties:

 {
    ∅,          # no input signals
    [
      h      :: VNode | Node -> VNode
      render :: Stream VNode -> Stream Node
      events :: Stream Node -> selector -> eventType -> Stream Event
    ],
    Node ->     # nodes as output signals
 }

HTTP driver would be even simpler:

{
    ∅,
    [
      get  :: Stream Req -> Stream Res
      post :: Stream Req -> Stream Res
      etc..
    ],
    ∅
}

Some code

Signals 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 virtual-dom: https://gist.github.com/milankinen/8d425a2b23fe426272ca

With those we can compare the difference of MVI and IMV approaches. First IMV:

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 MVI:

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
and pass its output signals out from parent

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

@laszlokorte
Copy link
Contributor

@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.

But wat wait??! How can we have response before request?

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 fooRes$ and fooReq$ as you like.

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:

const a = Counter({signals: signals.in("a"), DOM})
const b = Counter({signals: signals.in("b"), DOM})

Then you use a.DOM and b.DOM. Are those Streams of VNodes oder Streams of Nodes? I assume they are Streams of Nodes because it looks like they are a result of a render call. But how can they already be converted into nodes without the vdom diffing happend yet? And how can you mix vdom nodes with nodes?

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.
But in a more abstract way there does not need to be a connection between the view and the events at all.
A user could just say "hi computer, please delete the most recent item of the todo list" without the list ever being rendered. Only if the list then get's shown to the user some time later it's important the the item is not on the list anymore.

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.

@staltz
Copy link
Member

staltz commented Mar 6, 2016

@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:

  1. main() is pure
  2. All read effects are sources, all write effects are sinks
  3. Drivers are bridges between main() and external effects

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 isolate() is used with the implicit second argument. If the second arg is given, then it is pure. In practice, isolation impurity is not an issue, but your suggested architecture where effects happen between V=>I is very impure.

and there can't be intent without a view

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
The intent is keyboard-related but the view is DOM/GUI-related. There is no "discontinuity" because you don't need the effects from the view in order to build the intent.

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
On the left is the user, on the right is main() where inputs are "sources" and outputs are "sinks" and in between are hardware, OS, (Cycle.js?) drivers. The Cycle.js architecture mimics this closely. With your architecture, we lose those properties, because main() would take care of effects, it would cover much more than just the right side of the picture.

thus causing such isolation problems

The application can only make good guesses (=isolation) which input signals relate to which output signals.

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 isolate() and how it could be better so it would feel more obvious for developers. Isolation is gladly a solved problem in Cycle.js apps by now. Isolation is just about constraining a read effect to a particular scope, so it can be passed on to a subcomponent in the app.

"Truly reactive" apps do not have such problems

"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 dispatch() is spread throughout the code. If you want to know does a dispatch happen, you need to search for all the usages of dispatch().

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 req$. If you want to know what affects responses to happen, you just at the definition of res$. There is usually some causality between req$ and res$ but it's not necessarily a direct relationship, because it passes through the external world, and in principle the driver may not return a response for a given request.

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:

The essence of functional reactive programming is to specify the dynamic behavior of a value completely at the time of declaration.

And this applies to req$ and to res$.

How can we have response before request

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
"How can we have events on myinput before it even exists?"

The declarations in any main() are just building the pipeline/dataflow of how to get read effects and process them to become write effects. In an ideal situation, I'd prefer that JavaScript or DataFlowScript (some imaginary transpiled language, good for Cycle) would know how to handle declarations, ignoring the order they are written. We even have a feature suggestion in our backlog for that: #170 Because circular dependencies may happen, and JavaScript can't handle circular dependencies, we may need to create a Babel plugin that knows how to handle with circular dependencies. Ideally, you just specify what depends on what, and it doesn't matter if A was undefined before B, as long as both are defined somewhere in code.


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. :)

@laszlokorte
Copy link
Contributor

@theefer

Is that really true though? Looking at the code, events() actually results in a element.addEventListener on the root element. Yes it is lazily evaluated only once the streams are consumed, but it still feels like this operation on the sources results in a write-effect on the DOM (addition of a listener), which seems to violate the Cycle design goals?

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:
I assume you agree with me that the sum of two integers is a function (N,N)->N and that this function is as pure as you can get. There are absolutely no side effects. Same input -> same output.

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:

1. push 5
2. push 7
3. add

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?
No. because the addition of numbers has no state itself. Only the way to chose to get to the result may result in a process having state that changes over time.

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.
Modifying the DOM is not inherent to the concept of listening for events.

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.
It's like preferring assembler over a higher level language just for the reason that "you HAVE to load values onto the stack in order to add them"... Yeah in a computer you have to but try to tell some 10 year old that in order to add 5 and 7 you need a stack.
You are coupling your application logic to an implementation detail.


How would your sources.DOM.event('plus-clicks') function work? would it look up 'plus-clicks' in a hashmap? What if there is no entry yet because the DOM sink has not emitted yet ie there is no element yet that has an plus-clicks event?
Your function would still have to return an (for now empty) observable.
Now a few seconds later after the user has done some stuff the element with the 'on-click': 'plus-clicks' attribute get's added to the page. Now how is the DOM driver able to connect the event's of the element with the observable that has already been returned by the event() function earlier? The driver had to keep track of that observable in order to now connect it with the events of the element. How to keep track? In a list? in a hash map? Oh no! side effects! You can't get rid of them. You can only abstract them away so your application does not have to care about them anymore.

And that's exactly what the DOM driver is currently doing: DOM.select().event() abstracts the implementation detail of using addEventListener away so that when writing your application you do not have to care about how event handlers are added and you do not even have to know that there exist a method with the name addEventListener. Just as you do not have to care about all the nasty stateful cpu/ram/register/vm stuff that happens when you write this very pure line of code const x = 5 + 7;

@staltz
Copy link
Member

staltz commented Mar 14, 2016

Is that really true though? Looking at the code, events() actually results in a element.addEventListener on the root element. Yes it is lazily evaluated only once the streams are consumed, but it still feels like this operation on the sources results in a write-effect on the DOM (addition of a listener), which seems to violate the Cycle design goals?

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.

button('.handlePlus', {'on-click': 'plus-clicks'}, '+')

Now we just lost an important property.

Better respect the Cycle principle of making write-effects explicit in the sinks

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 intent, which determines what events are interesting. But in your suggestion, now those"interesting events" (e.g. click) are encoded in the view. It undermines the foundation.

@theefer
Copy link
Author

theefer commented Mar 14, 2016

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 isolate or not, is because the drivers that are injected as sources in each component actually act as global state. Drivers are essentially injecting the entire application state into each component function.

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 isolate comes in.

There may be cases where isolate is superfluous, but I would argue having to decide that is unnecessary overhead. However currently, isolate is only promoted as a solution to a problem, not a default practice.

One simple solution would be to update the docs to recommend using isolate on all components (though making it clear for advanced users that they can occasionally opt out of that if necessary). Maybe isolate could be renamed, or the simple makeComponent function helper made public?

For this to work, we also need to be confident that isolate doesn't introduce an unacceptable performance overhead, which would lead people back to have to cherry pick when and when not to use it.

(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:

  • Match using repeated identifiers (class name, request name or URI)
    • Error prone if manually duplicated as a string. It is stronger if a variable is used as we can then rely on JS syntax.
    • Risk of conflict if the component is not isolated.
    • If used for isolation, could improve performance of isolation layer, but we need a way to to guarantee uniqueness across the application. Most solutions to this are no pure (e.g. random, mutable global counter).
  • CSS class names mix events concerns with styling
    • Error prone as changes to styling may break the identifiers used for events.
    • Not the worse concern, but could fairly easily be avoided.
  • MVI vs IMV, and the disjointed logic resulting from the drivers architecture e.g. when dealing with multiple requests/response (raised by @milankinen)
    • Particularly apparent with the HTTP driver where one might otherwise have expected to flatMap over a sequence of asynchronous requests and their responses. Would also apply to all other request/response drivers, e.g. IndexedDB, etc.
    • Would require a significant rethink of the Cycle architecture.

Other concerns

A few other questions are raised but not as critical, or more subjective/contentious, so probably best ignored in this thread:

  • DOM.select(_).events(_) does a write-effect on the DOM
    • Could/should it be part of the sinks instead? Is that feasible?
  • Performance of matchesSelectors and many subscribers + filters
    • Intuitively it appears to be rather expensive, but there is no data to back up that claim at this point so best left aside until there is.

In my personal opinion, the most important change to improve developer ergonomics would be steps toward isolation by default, either by making isolate the best practice, or by relying on unique identifiers to match up between sinks and sources.

@laszlokorte
Copy link
Contributor

There may be cases where isolate is superfluous, but I would argue having to decide that is unnecessary overhead. However currently, isolate is only promoted as a solution to a problem, not a default practice.

One simple solution would be to update the docs to recommend using isolate on all components (though making it clear for advanced users that they can occasionally opt out of that if necessary). Maybe isolate could be renamed, or the simple makeComponent function helper made public?

👍 (except the naming, I like isolate as it's pretty concise)


  • Match using repeated identifiers (class name, request name or URI)
    • Error prone if manually duplicated as a string. It is stronger if a variable is used as we can then rely on JS syntax.
    • Risk of conflict if the component is not isolated.

just isolate the component. As you suggested calling isolate() should become a recommended convention. You have to keep in mind the case of using the same component twice anyway. Just hard coding unique ids inside the component definition will not be enough. The caller has to take care not passing the same source objects into two child components.

  • If used for isolation, could improve performance of isolation layer, but we need a way to to guarantee uniqueness across the application. Most solutions to this are no pure (e.g. random, mutable global counter).

Performance is almost always just an implementation detail. Avoid premature optimization. You should not change an architecture based on a wild guess.

  • CSS class names mix events concerns with styling

As i wrote earlier you do not have to use class names. Just do DOM.select('[data-action="doStuff"]').event('click')

  • MVI vs IMV, and the disjointed logic resulting from the drivers architecture

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.

  • DOM.select().events() does a write-effect on the DOM

Please see my previous answer.

In my personal opinion, the most important change to improve developer ergonomics would be steps toward isolation by default, either by making isolate the best practice, or by relying on unique identifiers to match up between sinks and sources.

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 isolate everywhere and you are good to go. All problems solved.

@arlair
Copy link
Contributor

arlair commented Mar 14, 2016

Another way to phrase this is: when is isolation not desirable?
There is one case I know of: http://staltz.com/adapting-controlled-and-uncontrolled-fields.html check the Input component

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?

@laszlokorte
Copy link
Contributor

@arlair I am not sure if I understand your question correctly. In that case the parent of the Input() component wants to listen for input events of the <input> element of the Input() component.

suggesting we aren't just good to go?

What do you mean by that?

@arlair
Copy link
Contributor

arlair commented Mar 14, 2016

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.

@laszlokorte
Copy link
Contributor

@arlair Well if you have a special case in which you do not want isolation you should not call it 😄
In such a case I would assume you know what you are doing.

@staltz
Copy link
Member

staltz commented Mar 14, 2016

Drivers are global sources (No isolation by default)

😬 Incorrect

the drivers that are injected as sources in each component actually act as global state.

😬 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 isolate(). It frees the parent from doing manual and tedious routing.

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.

Drivers are essentially injecting the entire application state into each component function.

😬 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.

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.

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.

However currently, isolate is only promoted as a solution to a problem, not a default practice.
One simple solution would be to update the docs to recommend using isolate on all components

This is the part where I agree with you. Honestly I thought it was obvious from the documentation already, because it starts by explaining isolateSource and isolateSink separately, which makes it obvious what isolate does is not anything magical, it simply tags the sinks with an ID and uses that ID when filtering the sources. I can of course add a sentence saying that it's a good idea to isolate every component as a rule of thumb.

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.

@theefer
Copy link
Author

theefer commented Mar 14, 2016

😬 Very incorrect. Drivers (functions!) don't even contain the application state. Read and write effects are not global state.

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. DOM.select()). With isolation, they can indeed be further filtered to only expose a subset of these effects.

"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.

One simple solution would be to update the docs to recommend using isolate on all components

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 isolate right before using a component, rather than when defining the component as the doc suggests.

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?

@staltz
Copy link
Member

staltz commented Mar 14, 2016

very few of the examples use isolate (only those that strictly require it), and all of these examples call isolate right before using a component, rather than when defining the component as the doc suggests.

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 isolate practice is demonstrated clearly.

That's a good action point, I'll make an issue out of it. #269

@milankinen
Copy link

So according to you there is an anomaly in the data flow because child receives events that did not flow through the parent. Do I understand you correctly?

@laszlokorte No but that's because I explained it very unclearly. 😄 What I meant is that because there is no real connection between DOM source and sink signals (only the conceptual "user"), it's virtually same as child's DOM source producing its own signals.

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).

@laszlokorte
Copy link
Contributor

@milankinen Ok I now I got what you are saying.
I would say that's a bug in the HTTP driver. A parent component should not be able to access responses to requests that were made by a child component:

const Parent({HTTP}) => {
  const child = isolate(Child)({HTTP});

  HTTP.subscribe((req) => {
    // this should not execute because the parent itself does no requests.
  });

  return {
    HTTP: child.HTTP;
  };
};

The same way that a component is not able to select child dom elements it should not be able to process child http requests.

@staltz
Copy link
Member

staltz commented Mar 15, 2016

That is actually by design, not a mistake. I remember we discussed the motivation somewhere, I need to dig up where was it.

@milankinen
Copy link

@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.

@milankinen
Copy link

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.

@laszlokorte
Copy link
Contributor

@milankinen

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.

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.
(no completely hidden from the parent. just not mixed up with it's own responses).
I would at least expect this behavior in order to be consistent with the dom driver.

regarding TSERS: As I already wrote in gitter I appreciate your effort. Especially the composition of components looks quite elegant.

@staltz
Copy link
Member

staltz commented Mar 17, 2016

I would at least expect this behavior in order to be consistent with the dom driver.

This is where the DOM Driver and HTTP Driver differ. In reality they don't differ, because DOM Driver has .select() (fancy filter) and HTTP Driver doesn't, but in both the parent source contains everything the child source needs. In the case of DOM, we usually use .select anyway, so the parent is already filtering out for its own stuff.

@theefer
Copy link
Author

theefer commented Mar 17, 2016

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.

@wclr
Copy link
Contributor

wclr commented Mar 17, 2016

@theefer

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.

it just the idea behind current isolate implementation no?

@theefer
Copy link
Author

theefer commented Mar 17, 2016

Not quite, especially in the case of HTTP, as per the case describe above of parent/child exposure?

@wclr
Copy link
Contributor

wclr commented Mar 17, 2016

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 select does in DOM driver to isolate responses.
Maybe I will add something like this there https://github.com/whitecolor/cycle-async-driver

@wclr
Copy link
Contributor

wclr commented Mar 17, 2016

This issue relates to possible isolation mechanics of HTTP driver
#272

@staltz
Copy link
Member

staltz commented Mar 18, 2016

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.

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.

@ivan-kleshnin
Copy link
Contributor

ivan-kleshnin commented May 10, 2016

@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).
There is "derived state" (state which depends on state like dynamic flags).
There are "updates" if you use functional style of state reducers (accepting raw fn stream instead of action stream).

So it's getting closer to:

i-m-v

One more possibility is:

m-i-v

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 state and intents do not directly depend on each other here so can be swapped.

I made apps with both and both worked reasonably fine 😄

@milankinen
Copy link

@ivan-kleshnin Do you have the app sources available somewhere? I'd be interested in taking a look at them.

@ivan-kleshnin
Copy link
Contributor

Yes.

First diagram: https://github.com/ivan-kleshnin/cyclejs-examples/tree/master/3.0-crud/src
Second diagram: https://github.com/ivan-kleshnin/cyclejs-examples/tree/master/3.0-crud.alt/src

I've been experimenting with different CycleJS dataflows for last two months.
And I've hit some problems I believe You described somewhere (here / on tsers / on gitter – I forgot).
Complexity of reasoning about dataflow for Intent <- Model <- View order in particular.
So I was like "Wait, we were kinda warned about it..."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests