staltz / cycle-onionify Goto Github PK
View Code? Open in Web Editor NEWMIGRATED! This was transfered to https://cycle.js.org/api/state.html
License: MIT License
MIGRATED! This was transfered to https://cycle.js.org/api/state.html
License: MIT License
Hi
Reading the README and others examples I was surprised at the use of "Action" when describing the intents.
Indeed, it introduces a new term whereas we speak of MVI, aka Model View Intent, where the term action is completely out of the picture.
Furthermore, what's called actions here are some intents: it's not because the user "asks for it" that it'll happen. Some business logic might contradict the user's will here. For example the chosen login might be already given to someone else.
So, why not call these actions intents?
The model would then get a stream of Intent, which is way more in sync with MVI, and all would be simpler.
I suspect I'm missing something but I don't see what so...
++
What are the good patterns for persistence when using onionify
I guess we can hydrate in an initial/default reducer, either in in the top level component or individual components according to your preferences?
How about persisting though?
Or do you think there's no point being prescriptive? Even if this is your view examples would be helpful.
BTW I thought this was a very interesting series on state - https://getpocket.com/a/read/1657730920
Don't understand this as an action I will do soon, I want to just collect feedback about an idea I just had:
https://twitter.com/andrestaltz/status/885185030405992452
I'd really like to rename
getter
/setter
tozoomIn
/zoomOut
to avoid confusion with the OO getter() => T
and setterT => void
I know
() => T
is essentially effectful and not present in FP, but most programmers learning FP will read "getter" and think() => T
.
The actual change in onionify would be
export type Lens<P, C> = {
- get: Getter<P, C>;
- set: Setter<P, C>;
+ zoomIn: ZoomIn<P, C>;
+ zoomOut: ZoomOut<P, C>;
};
and would be a breaking change.
It appears it will work with 10 but not unified
It would be nice if state$
emissions for a component get filtered until the default reducer for that component gets run... Is that possible?
Below is an explanation of one of the reasons that would be useful...
In general, one of the things I've noticed with onionify and RxJS (and I assume this applies to xstream as well) is the importance of onion
stream merge ordering. For example if I have a component Foo
which has children A
and B
, then the full set of reducers coming from Foo is the merge of local reducers from Foo
and the reducers coming from children A
and B
. If I write that as:
return {
...
onion: O.merge(A.onion, B.onion, local_reducer$)
}
Then the "merged" reducers stream, (which conceptually has no ordering), will get subscribed in the order given. So A.onion
will get subscribed first, then B.onion
, then local_reducer$
. This means that very likely the local default_reducer
will not be run before the childrens' default reducer
streams are processed which temporarily causes odd state$
emissions for Foo
(...and to it's children if the parent state information gets lensed down). This invalid state persists until all the reducers from the merge have been subscribed and the state$
catches up, i.e. all the default_reducer
streams associated with the merge have been processed.
In general being able to assume that the default reducer for a component has been run before the state$
for a component emits is a convenient property since it means I don't need to write defensive code within the parts of my component which read the state$
. Of course it is possible to add a filtering flag to the state indicating onion_valid: true
, but that's hacky and ugly.
It should be noted, and strongly, that this issue can be avoided by simply placing local_reducer$
as the first entry in the merge. So O.merge(local_reducer$, A.onion, B.onion)
does not have this same issue since the parent's default reducer will be subscribed before the default reducers from the children. Easy enough, but it's not intuitive...
The non-intuitiveness is why I'm raising this as an issue.
Hey I'm new to reactive programming and I want to learn it with a cyclejs project. Currently i'm testing onionify. I want to send a http request inside a child component, but I dont get to manage to get the current state inside the model.
My model look like this:
export function model(http, intent, prevState) {
const default$: Stream<Reducer> = xs.of(function defaultReducer(): any {
if (typeof prevState === 'undefined') {
return Object.assign({}, {
title: '',
description: '',
tags: '',
});
} else {
return prevState;
}
});
const titleChange$: Stream<Reducer> = intent.inputTitle$
.map(ev => (ev.target as any).value)
.map(title => function titleReducer(prevState){
return Object.assign({}, prevState, {
title: title
});
});
...
I tried something like this to send a http request:
const submitReducer$: Stream<Reducer> = intent.submit$
.map(ev => "")
.map(submit => function submitReducer(prevState){
return Object.assign({}, prevState, {});
})
const submitForm$ = submitReducer$
// At this map the state is the submitReducer() function.
--> .map(state => ({
url: 'http://localhost:8080/api/notecard',
method: 'POST',
category: 'post-notecard',
send: {
'title': state.title,
'description': state.description,
'tags': state.tags,
}
}));
At this map the state is the submitReducer() function. But I would like to have the current state at this point to make a POST request
const reducer$ = xs.merge(
default$,
titleChange$,
descChange$,
tagsChange$,
visibilityChange$,
submitReducer$
);
...
const sinks = {
HTTP: submitForm$
onion: reducer$
};
It would be helpful, if you could inject an initial state into onionify.
So basicly:
onionify(main, { count: 40 })(sources);
This would make testing with default reducers using prevState easier
Currently the StateSource assumes this.state$
has a compose
method, but that is not the case when adapt
gets called with a non-xstream library.
https://github.com/staltz/cycle-onionify/blob/master/src/index.ts#L111
this.state$ = adapt(stream.compose(dropRepeats()).remember());
The above line returns a stream in the app's chosen stream lib, so when select
gets called on the StreamSource, this line fails since this.state$
is not an xstream stream.
https://github.com/staltz/cycle-onionify/blob/master/src/index.ts#L121
return new StateSource<R>(
this.state$.map(get).filter(s => typeof s !== 'undefined'),
null,
);
I could create a PR with this change which I think will fix the problem...
export class StateSource<T> {
public state$: MemoryStream<T>;
private _name: string | null;
constructor(stream: Stream<any>, name: string | null) {
this._name = name;
this._state$ = stream.compose(dropRepeats()).remember()
this.state$ = adapt(this._state$);
if (!name) {
return;
}
(this._state$ as MemoryStream<T> & DevToolEnabledSource)._isCycleSource = name;
}
public select<R>(scope: Scope<T, R>): StateSource<R> {
const get = makeGetter(scope);
return new StateSource<R>(
this._state$.map(get).filter(s => typeof s !== 'undefined'),
null,
);
}
public isolateSource = isolateSource;
public isolateSink = isolateSink;
}
As the state becomes fractal I wonder how sharing data across application not in a parent-child component hierarchy is possible. Its easy to imagine that this is super common scenario. I guess its possible to communicate the closest common ancestor so it could store this shared state, but what about refactoring? From what I've seen you need to go 'manually' through each layer so if that's the solution then you need to refactor the code so you will reference i.e. to the 4th and not 3rd ancestor. Maybe I'm missing something here, not a Cycle expert anyway, but im rly interested in the topic.
This is not quite a bug report, more like a token of something to figure out when things are more mature.
I'm wondering about the composability of Cycle add-ons which arrive as a function which wraps the application main function. Once an application uses several of those, it needs to choose what order to wrap them. Wrapping does not seem to compose as well as adding drivers.
The specific case I hit was with onionify and cyclejs-modal:
https://github.com/cyclejs-community/cyclejs-modal
both of which arrive as a function to wrap around the application main function.
I ended up with this wrapping order:
const main = modalify(onionify(App));
... which yields both libraries working, with the caveat that onion state is not available within a component instantiated by modalify. This requires a workaround for state, carrying state needed for the modal by some means other than onion. (I briefly dabbled with a global variable, the allure of the dark side is sometimes strong...)
If I wrap them the other way, onion state is available inside the component instantiated by modalify - but modalify fails to work, for reasons I did not debug.
I would guess the fundamental challenge is: I'm using two libraries both of which expect to be the "outermost" wrapper function. Staring at the source code of both things, it's not clear to me what could be done to either to make their functionality mutually available to each other.
The collection$
returned from asCollection
should be adapted.
return adapt(collection$);
https://github.com/staltz/cycle-onionify/blob/collection/src/index.ts#L233
--- UPDATE: SEE COMMENTS BELOW FOR A BETTER PROPOSAL ---
I've created a branch to experiment with lensed (or, more precisely, optic'd) state. It uses partial.lenses. The idea is to retain the scope-based isolation of the state object, while allowing to associate an optic (lens, traversal, or isomorphism) to a scope name, so that children components can see the state through said optics.
For example one can have the following state:
const reducer$ = xs.of({kelvin: 283});
and compute derived values through:
const optics$ = xs.of({
// this component sees the state as it is
self: L.identity,
children: {
kelvin: {
// a child isolated with 'kelvin' sees 283 as state
self: L.prop('kelvin')
},
celsius: {
// a child isolated with 'celsius' sees 9.85 as state
self: L.compose(
L.prop('kelvin'),
L.iso(a => a - 273.15, b => b + 273.15)
)
},
fahrenheit: {
// a child isolated with 'fahrenheit' sees 49.73 as state
self: L.compose(
L.prop('kelvin'),
L.iso(a => a * 9/5 - 459.67, b => (b + 459.67) * 5/9)}
)
}
}
});
The component must then return {..., onion: {optics$, reducer$}}
instead of the usual {..., onion: reducer$}
(which however is still supported). The children components access the state through the optics (in this case isomorphisms) in a transparent way: they don't need to know about partial.lenses and they can update the value.
I've added two examples, "mixed-optics" and "mixed-optics-nested", which use slightly different approaches. The differences with the original "mixed" example are minimal.
This code is just a proof of concept. It's probably buggy and I haven't thought of performance at all. I'd just like to know what the community thinks of such an approach.
I think the pros of this solution are:
I pushed to the collection branch an experimental feature where we borrow an API from Cycle Collection and improve performance under the hood.
The gist is:
Create many instances of a component using collection
function MyList(sources) {
// ...
// Expects sources.onion.state$ to be a Stream<Array<ItemState>>
const instances$ = collection(Item, sources, itemState => itemState.key)
// the above is the same as
//const instances$ = collection(Item, sources)
pickCombine('DOM') is pick('DOM') + mix(xs.combine)
const childrenvnodes$ = instances$.compose(pickCombine('DOM'))
pickMerge('onion') is pick('onion') + mix(xs.merge)
const childrenreducer$ = instances$.compose(pickMerge('onion'))
Note: this is an experiment. That's why it's in a branch.
The motivation for this was to make onionify faster for large amounts of child components from an array. The main solution for this was pickCombine
and the internal data structure of "instances" which collection
builds. pickCombine
is a fusion of pick+mix to avoid a combine, a flatten, and a map, and does all of these together and takes shortcuts to avoid recalculating things in vain. collection
was sort a necessity from two perspectives: (1) easier than creating and isolating item components by hand, (2) with this performance improvement it would have become even harder to do it by hand.
Check the advanced example.
We're looking for feedback in order to find the most suitable API that feels quick to learn for the average programmer, while solving the problems below too. In other words, the challenge here is API design for developer experience. The challenge is not "how" to solve the technical problem.
Open problems/questions:
mixed
?collection()
does a lot under the hood (creates the instances data structure, calls onionify, picks the key from each item state, creates item state lenses, onionifies each child component, etc). Do we want to have heavy configuration with many arguments, or do we want to break that down into other helper functions? And how?key
. Do we still want to allow keyless items in a list?Checklist before final release:
asCollection
to toCollection
This issue in Cycle.js could be actually for this repo cyclejs/cyclejs#512
steps to reproduce:
git clone https://github.com/staltz/cycle-onionify.git
npm i
npm run lib
cd examples/lenses/
npm start
node 8.1.4
npm 6.4.1
alex@alexs-mbp : ~/dev/learn/cyclejs
[0] % git clone https://github.com/staltz/cycle-onionify.git
Cloning into 'cycle-onionify'...
[...]
alex@alexs-mbp : ~/dev/learn/cyclejs
[0] % cd cycle-onionify
alex@alexs-mbp ‹ master › : ~/dev/learn/cyclejs/cycle-onionify
[0] % npm i
> [email protected] install /Users/alex/dev/learn/cyclejs/cycle-onionify/node_modules/fsevents
> node install
[fsevents] Success: "/Users/alex/dev/learn/cyclejs/cycle-onionify/node_modules/fsevents/lib/binding/Release/node-v57-darwin-x64/fse.node" already installed
Pass --update-binary to reinstall or --build-from-source to recompile
npm notice created a lockfile as package-lock.json. You should commit this file.
added 580 packages from 203 contributors and audited 2610 packages in 20.678s
found 0 vulnerabilities
alex@alexs-mbp ‹ master ● › : ~/dev/learn/cyclejs/cycle-onionify
[0] % npm run lib
> [email protected] prelib /Users/alex/dev/learn/cyclejs/cycle-onionify
> mkdir -p lib
> [email protected] lib /Users/alex/dev/learn/cyclejs/cycle-onionify
> tsc
alex@alexs-mbp ‹ master ● › : ~/dev/learn/cyclejs/cycle-onionify
[0] % cd examples/lenses/
alex@alexs-mbp ‹ master ● › : ~/dev/learn/cyclejs/cycle-onionify/examples/lenses
[0] % l
total 24
-rw-r--r-- 1 alex staff 366 Oct 6 12:17 index.html
-rw-r--r-- 1 alex staff 695 Oct 6 12:17 package.json
drwxr-xr-x 6 alex staff 204 Oct 6 12:17 src
-rw-r--r-- 1 alex staff 385 Oct 6 12:17 tsconfig.json
alex@alexs-mbp ‹ master ● › : ~/dev/learn/cyclejs/cycle-onionify/examples/lenses
[0] % npm start
> [email protected] start /Users/alex/dev/learn/cyclejs/cycle-onionify/examples/lenses
> npm install && npm run browserify && echo 'OPEN index.html IN YOUR BROWSER'
npm notice created a lockfile as package-lock.json. You should commit this file.
added 160 packages from 194 contributors and audited 1040 packages in 15.377s
found 0 vulnerabilities
> [email protected] prebrowserify /Users/alex/dev/learn/cyclejs/cycle-onionify/examples/lenses
> mkdirp dist && tsc
src/Edit.ts(42,3): error TS2322: Type '{ DOM: MemoryStream<VNode>; onion: Stream<Reducer>; }' is not assignable to type 'Sinks'.
Types of property 'DOM' are incompatible.
Type 'MemoryStream<VNode>' is not assignable to type 'Stream<VNode>'.
Property '_ils' is protected but type 'Stream<T>' is not a class derived from 'Stream<T>'.
src/Item.ts(38,3): error TS2322: Type '{ DOM: MemoryStream<VNode>; onion: Stream<(prevState: State) => State>; }' is not assignable to type 'Sinks'.
Types of property 'DOM' are incompatible.
Type 'MemoryStream<VNode>' is not assignable to type 'Stream<VNode>'.
src/main.ts(7,23): error TS2345: Argument of type '(sources: Sources) => Sinks' is not assignable to parameter of type 'MainFn<Sources, OSi<{}>>'.
Type 'Sinks' is not assignable to type 'OSi<{}>'.
Types of property 'onion' are incompatible.
Type 'Stream<Reducer>' is not assignable to type 'Stream<Reducer<{}>>'.
Property '_ils' is protected but type 'Stream<T>' is not a class derived from 'Stream<T>'.
npm ERR! code ELIFECYCLE
[...]
Hey,
I hope this is the right place to ask for help. I'm currently trying to get started with CycleJS and cycle-onionify
and have troubles getting my app to produce the right output. Currently it is a simple input field, on every enter a message object is created and should then be rendered in a list. With starting to split that into multiple, smaller components, I see no output of my message list anymore (only my input field). To be more specific: I can log the list of messages (which contain the correct amount of messages) and in my Message-component I also get a log output for the sources, so the code is at least touched (means: 2 messages in the list, 2 logs in my console; 3 when I add one more message and so on).
My Message-component looks like the following:
import xs from 'xstream';
import { div, p } from '@cycle/dom';
import isolate from '@cycle/isolate';
const calculateTimeString = timestamp => {
const date = timestamp && new Date(timestamp);
return `${date.toLocaleDateString()}: ${date.toLocaleTimeString()}`;
};
function Message(sources) {
const state$ = sources.onion.state$;
console.log(state$); // --> logs a MemoryStream
const vdom$ = state$.map(state => {
// HELPME this does not produce any output
console.log('Message', state);
return div('.messageItem', [
p(calculateTimeString(state.time)),
p(state.message),
]);
});
return {
DOM: vdom$,
onion: xs.empty(),
};
}
// (sources) => ...
export default isolate(Message);
and is integrated into the following MessageList:
import { ul } from '@cycle/dom';
import isolate from '@cycle/isolate';
import { pick, mix } from 'cycle-onionify';
import xs from 'xstream';
import Message from './../Message';
function MessageList(sources) {
const state$ = sources.onion.state$;
const childrenSinks$ = state$.map(messages => {
// --> with every new message, this logs me the array of messages [{...}, {...}, ...]
console.log('MessageList', messages);
return messages.map((msg, i) => {
return isolate(Message, i)(sources);
});
});
const vdom$ = childrenSinks$
.compose(pick(sinks => sinks.DOM))
.compose(mix(xs.combine))
.map(ul);
const reducer$ = childrenSinks$
.compose(pick(sinks => sinks.onion))
.compose(mix(xs.merge));
return {
DOM: vdom$,
onion: reducer$,
};
}
export default MessageList;
For the sake of completeness, everything is integrated into this main app:
import xs from 'xstream';
import { div, p, input } from '@cycle/dom';
import isolate from '@cycle/isolate';
import { prepend } from 'ramda';
// FIXME how to strcutre components? why not automatically importing index?
import MessageList from './components/MessageList/index';
function intent(domSource) {
return domSource
.select('.message')
.events('keydown')
.filter(({ keyCode, target }) => keyCode === 13 && target.value !== '')
.map(ev => {
const val = ev.target.value;
// eslint-disable-next-line no-param-reassign
ev.target.value = '';
return {
time: Date.now(),
message: val,
};
});
}
function model(action$) {
const initReducer$ = xs.of(() => ({ messages: [] }));
const updateReducer$ = action$.map(message => prevState => ({
messages: prepend(message, prevState.messages),
}));
return xs.merge(initReducer$, updateReducer$);
}
export default function App(sources) {
const messageListSinks = isolate(MessageList, 'messages')(sources);
const action$ = intent(sources.DOM);
const parentReducer$ = model(action$);
const messageListReducer$ = messageListSinks.onion;
const reducer$ = xs.merge(parentReducer$, messageListReducer$);
const vtree$ = messageListSinks.DOM.map(listNode =>
div([
p('Eingabe einer neuen Nachricht:'),
input('.message', { attrs: { type: 'text', autofocus: true } }),
listNode,
])
);
return {
DOM: vtree$,
onion: reducer$,
};
}
Am I missing something? Am I doing something wrong? I tried to stick to the example here in the repo, but cannot find my error.
Hope one of you can help me out, in case you need more infos or the whole source-code, please let me know!
How to be when scope named as state property is not enough:
// this needs 'child' from onion state
isolate(Child, 'child')({onion, HTTP)
// and this needs 'child' from onion state,
// but HTTP source needs different scope not to mess with `Child`
isolate(AnotherChild, 'child')({onion, HTTP)
What if one need to pass more nested property to child like "some.nested.child"
// with mapping I would pass it like:
onion.state$.map(state => state.some.nested.child)
Say I have an online store, and I want to make a price range selector with a double slider and two inputs for setting exact range limits, like so:
0.....|_______|......1000
--------- ----------
| 250 | | 700 |
--------- ----------
So the container RangePicker
component may have three children: Slider
, From
, and To
. The state of RangePicker
is defined by two integers: {from: 250, to: 700}
. It's easy to pass them to the inputs: just isolate by from
and to
correspondingly. But how do I access those values from Slider
component, if I still want to isolate its DOM
sink?
I understand that currently the only way to remove an unused child component's property from the onion state is to have the parent set it's state value to undefined
. This seems not convenient or ideal. Ideally a child component can be configured to automatically/implicitly emit an UndefinedReducer from it's onion stream (state => undefined
) when it's onion stream is unsubscribed. Is that possible?
If you have a child that is e.g. not using HTTP, and you try
collectSinks: instances => ({
HTTP: instances.pickMerge('HTTP')
})
onionify makeCollection
just throws an error. It would make sense to just return xs.never()
I thought about it, and basicly this will block the combine from emitting at all, so it is basicly a silent failure. For merge
never
is the right thing to do, but the other half of my PR might be better to roll back
background: cyclejs/todomvc-cycle@065c304#commitcomment-19533736
const taskSinks$ = array$.map(array =>
array.map((item, i) => isolate(Task, i)(sources))
here, the Task
component function is called again and again for each item whenever another item is added, removed or updated. So instead of usual workflow, where component or main
function is used only for initial setup and then the stream library handles the changes, we run this setup over and over again. In fact, sources.onion.state$
inside the child component never emits twice, so it's no better then just plain value.
André suggested, that memoization could help here, and it definitely will, but it doesn't look possible without introducing ids
Make proper types, potentially using keyof
to avoid the any
types that are currently in pick and mix.
It seems legal to have as state a value that is not an object, for example an integer, a string or a boolean. Hovever, when such a value is falsy it gets filtered out (https://github.com/staltz/cycle-onionify/blob/master/src/index.ts#L79).
Non-object state values should be either forbidden or fully supported (including falsy values).
Code to reproduce the issue:
import xs from 'xstream';
import Cycle from '@cycle/xstream-run';
import {div, button, p, makeDOMDriver} from '@cycle/dom';
import onionify from '../../../lib/index';
import isolate from '@cycle/isolate'
function Counter(sources) {
const action$ = xs.merge(
sources.DOM.select('.decrement').events('click').map(ev => -1),
sources.DOM.select('.increment').events('click').map(ev => +1)
);
const state$ = sources.onion.state$;
const vdom$ = state$.map(state =>
div([
button('.decrement', 'Decrement'),
button('.increment', 'Increment'),
p('Counter: ' + state)
])
);
const updateReducer$ = action$.map(num => function updateReducer(prevState) {
return prevState + num;
});
return {
DOM: vdom$,
onion: updateReducer$,
};
}
function main(sources) {
const counterSinks = isolate(Counter, 'count')(sources);
const vdom$ = counterSinks.DOM;
const initReducer$ = xs.of(function initReducer() {
return {count: 1};
});
const updateReducer$ = counterSinks.onion;
const reducer$ = xs.merge(initReducer$, updateReducer$);
return {
DOM: vdom$,
onion: reducer$,
};
}
const wrappedMain = onionify(main);
Cycle.run(wrappedMain, {
DOM: makeDOMDriver('#main-container')
});
Expected behavior:
Clicking "Decrement" once should change the counter value to 0.
Actual behavior:
The counter value is in internally updated, but the isolated component won't see the update. Thus the counter can be set to any value except 0.
Versions of packages used:
cycle-onionify: 2.3.0
@cycle/base: 4.1
@cycle/isolate: 1.4
Note that the following (without isolation) works fine:
import xs from 'xstream';
import Cycle from '@cycle/xstream-run';
import {div, button, p, makeDOMDriver} from '@cycle/dom';
import onionify from '../../../lib/index';
function main(sources) {
const action$ = xs.merge(
sources.DOM.select('.decrement').events('click').map(ev => -1),
sources.DOM.select('.increment').events('click').map(ev => +1)
);
const state$ = sources.onion.state$;
const vdom$ = state$.map(state =>
div([
button('.decrement', 'Decrement'),
button('.increment', 'Increment'),
p('Counter: ' + state)
])
);
const initReducer$ = xs.of(function initReducer() {
return 0;
});
const updateReducer$ = action$.map(num => function updateReducer(prevState) {
return prevState + num;
});
const reducer$ = xs.merge(initReducer$, updateReducer$);
return {
DOM: vdom$,
onion: reducer$,
};
}
const wrappedMain = onionify(main);
Cycle.run(wrappedMain, {
DOM: makeDOMDriver('#main-container')
});
Currently Omit<T, K>
type is defined as a mere alias of any
, which results in the situation where the core onionify
function is quite loosely typed.
cycle-onionify/src/onionify.ts
Line 27 in 8b6344a
As you know, however, we already have conditional types in TypeScript 2.8+, so we can define the type properly with Pick
and Exclude
.
It also has some drawbacks:
thank you
"Reducer" is, by definition, a binary function of the form (a, b) => a
(left fold) or (a, b) => b
(right fold). In this project "reducer" term is used for state => state2
like transformations, so I believe it's misleading.
I suppose the name was chosen as "familiar" after Redux, but I think that being technically precise is more important. The real reducer is this guy:
(state, fn) => fn(state)
The mapper
term would be closer, btw.
cycle-onionify/rxjs-typings.d.ts
Line 1 in bfcb5f3
MakeScopesFn
does not exist in ./lib/types
but imported by rxjs-typings.d.ts
and most-typings.d.ts
im guessing it was because of the collection/onionify merge and the typing for rxjs and most weren't updated.
would love to help but my typescript knowledge is still a little behind.
onionify expects you to use the onion
key from its typings, even though you can set it yourself. Not sure if it is work to try to type the transformation without proper row types.
Wouldn't it be relatively simple to make an immutable.js version? I am using immutable.js for anything state-related so a cycle-onionify-immutable would be fantastic! 😃
Distinction:
A reducer is a function reducer :: Action -> State -> State
.
An action is a function action :: State -> State
.
What you describe as returning to onion
is a stream of actions not a stream of reducers.
A stream of actions also has a more intuitive meaning.
onionify acutally causes a scope hoisting bailout for @cycle/isolate
that would be fixed with a ES6 build
Code to reproduce the issue:
https://www.webpackbin.com/bins/-Kn5YFWZpUDpC81ugNXC
import xs, {Stream} from 'xstream';
import {run} from '@cycle/run';
import {makeDOMDriver, DOMSource, VNode, div} from '@cycle/dom';
import onionify, {StateSource} from 'cycle-onionify';
import isolate from '@cycle/isolate';
import sampleCombine from 'xstream/extra/sampleCombine';
type State = {
id: string,
val: number,
children: Array<State>,
}
type Reducer = (prevState: State) => State;
type Sources = {
DOM: DOMSource,
onion: StateSource<State>,
}
type Sinks = {
DOM: Stream<VNode>;
onion: Stream<Reducer>;
}
type ChildrenSources = {
DOM: DOMSource,
onion: StateSource<Array<State>>,
}
function Item(sources: Sources): Sinks {
const reducers = [
(prevState: State): State => ({id: prevState.id, val: prevState.val, children: [{id: 'b', val: 5, children: []}]}),
(prevState: State): State => ({id: prevState.id, val: prevState.val, children: []}),
(prevState: State): State => ({id: prevState.id, val: prevState.val, children: [{id: 'b', val: 5, children: []}]}),
];
const updateReducer$ = sources.onion.state$
.filter(state => state.id === 'a')
.mapTo(xs.periodic(1000).take(3))
.flatten()
.map(n => reducers[n]);
const childrenSinks = isolate((srcs: ChildrenSources): Sinks => {
const children = srcs.onion.toCollection(Item)
.uniqueBy((s: State) => s.id)
.build(srcs);
return {
DOM: children.pickCombine('DOM').map(div),
onion: children.pickMerge('onion'),
};
}, 'children')(sources);
const vdom$ = sources.onion.state$.compose(sampleCombine(childrenSinks.DOM))
.map(([state, childrenVdom]) =>
div([String(state.val), childrenVdom])
);
const reducer$ = xs.merge(updateReducer$, childrenSinks.onion as Stream<Reducer>);
return {
DOM: vdom$,
onion: reducer$,
};
}
function App(sources: Sources): Sinks {
const itemSinks = Item(sources);
const vdom$ = itemSinks.DOM;
const initReducer$ = xs.of((): State => ({id: 'a', val: 1, children: [] }))
const reducer$ = xs.merge(initReducer$, itemSinks.onion);
return {
DOM: vdom$,
onion: reducer$,
}
}
const main = onionify(App);
run(main, {
DOM: makeDOMDriver('#main-container')
});
Expected behavior:
The child item {id: 'b', val: 5}
is added, removed, and added again to the parent item {id: 'a', val: 1}
.
Actual behavior:
The app crashes upon re-adding the item {id: 'b', val: 5}
. To reproduce this bug it seems necessary that:
an item with the same key is added, removed, and added again
there is a nesting of items
Versions of packages used:
@cycle/dom
17.4.0
@cycle/isolate
3.0.0
@cycle/run
3.1.0
cycle-onionify
4.0.0-rc.10
xstream
10.8.0
From reading many words around cycle-onionify
, I have a sense it is possibly on a path to become the "in the box" state mechanism for Cycle. Hence this question/suggestion is here rather than in the main cycle repo. Feel free to send me elsewhere if this is a poor assumption. :-)
cycle-onionify
provides a convenient and conventional place to put component-scoped state, accessible as needed from parent components yet isolated by default. There is a mechanism coming along here shortly from @staltz to handle collections efficiently and conveniently also. There is a mechanism to fit "global" application-wide state, in onionify - with tedious layers of lenses.
My question and suggestion therefore, is that one of these pieces (Cycle core, or onionify, or another library?) provide a conventional mechanism for non-onion state. For application wide-state. Perhaps this could be a sister project to onionify; or perhaps onionify could include the functionality. I think what I'm asking for is about 10% of what onionify already does, mostly the notion of providing a conventionally named source and sink throughout, with reducers and fold(). Is easy to re-create this on a per-project basis, but easier to explain to other people if it is a convention in the box.
(Of course one could use https://github.com/cyclejs-community/redux-cycles for application wide state; I'm hoping for something for applications where the action layer of Redux is unnecessary complexity. redux-cycles has claimed the source key STATE, maybe something different for non-redux application-wide state?)
In this code example, the log
driver only receives the second 'b' event:
import {run} from '@cycle/run'
import {makeDOMDriver} from '@cycle/dom'
import onionify from 'cycle-onionify';
import {div} from '@cycle/dom'
import xs from 'xstream'
function App(sources) {
const state$ = sources.onion.state$;
const init_reducer$ = xs.of(s => 'a');
const change_a_to_b_reducer$ = state$
.filter(s => s === 'a')
.map(() => s => 'b');
return {
DOM: state$.map(s => div(s)),
onion: xs.merge(init_reducer$, change_a_to_b_reducer$),
log: state$,
}
}
const main = App;
const wrappedMain = onionify(main);
const drivers = {
DOM: makeDOMDriver('#app'),
log: msg$ => {
msg$.addListener({next: i => console.log('passthrough', i)})
},
};
run(wrappedMain, drivers);
But when I change the order of the drivers, both 'a' and 'b' event are logged:
return {
log: state$,
DOM: state$.map(s => div(s)),
onion: xs.merge(init_reducer$, change_a_to_b_reducer$),
}
So apparently the order of the drivers has impact. I have the same problem when using the HTTP driver.
Thank you for your help!
Hi guys,
I am diving into onionify but I am getting "RangeError: Maximum call stack size exceeded".
What am I doing wrong?
Webpackbin not working atm so pasting code here.
My index.js:
import { run } from '@cycle/xstream-run';
import { button, div,
makeDOMDriver, pre } from '@cycle/dom';
import xs from 'xstream';
import isolate from '@cycle/isolate';
import Foo from './foo';
import onionify, {pick, mix} from 'cycle-onionify';
function intent(sources) {
return xs.merge(
sources.DOM.select('.decrement').events('click').map(ev => -1),
sources.DOM.select('.increment').events('click').map(ev => +1)
);
}
function model(action$) {
const fnInit$ = xs.of(() => []);
const fnAdd$ = action$.filter(n => n > 0).map(n => state => state.concat({ text : 'abc'}));
return xs.merge(fnInit$, fnAdd$);
}
function view(state$) {
return state$.map(state =>
div([
button('.decrement', 'Remove'),
button('.increment', 'Add'),
pre(JSON.stringify(state, null, 4))
])
);
}
function main(sources) {
const array$ = sources.onion.state$;
const foos$ = array$.map(array =>
array.map((item, index) => isolate(Foo, index)(sources))
);
const fooReducers$ = foos$.compose(pick('onion'));
const fooReducer$ = fooReducers$.compose(mix(xs.merge));
const action$ = intent(sources);
const parentReducer$ = model(action$);
const vdom$ = view(array$);
const reducer$ = xs.merge(parentReducer$, fooReducer$);
return {
DOM: vdom$,
onion: reducer$,
};
}
const wrappedMain = onionify(main);
run(wrappedMain, {
DOM: makeDOMDriver('#app')
});
My foo.js:
import { div, input } from '@cycle/dom';
import xs from 'xstream';
import { spy } from '../../src/ent/vdom/utils';
function intent(sources) {
return sources.DOM
.select('.text')
.events('input')
.map(ev => ev.target.value);
}
function model(action$) {
const fnInit$ = xs.of(state => state ? state : ({ text : 'bar' }));
const fnUpdate$ = action$.map(text => state => ({ text }));
return xs.merge(fnInit$, fnUpdate$)
}
function view(state$) {
const fnVtree = state =>
div([
input('.text'),
div(state.text),
spy(state)
]);
return state$.map(fnVtree);
}
function main(sources) {
const state$ = sources.onion.state$;
const action$ = intent(sources);
const reducer$ = model(action$);
const vtree$ = view(state$);
return {
DOM : vtree$,
onion : reducer$
}
}
export default main;
Not entirely sure why, but I have a reproducing case:
Live demo at webpackbin
Expected behavior:
App logs events on button click
Actual behavior:
Nothing happens
import xs from 'xstream';
import { run } from '@cycle/run';
import { div, button, makeDOMDriver } from '@cycle/dom';
import onionify, { makeCollection } from 'cycle-onionify';
function Item(sources) {
//works
/*sources.DOM.select('.tab').events('click')
.addListener({ next: console.log });*/
return {
DOM: xs.of(div([button('.tab', {}, ['Test'])])),
log: sources.DOM.select('.tab').events('click')
}
}
const List = makeCollection({
item: Item,
itemKey: x => x.id, // does not matter
itemScope: x => '._' + x, // does not matter
collectSinks: instances => ({
DOM: instances.pickCombine('DOM').map(div),
log: instances.pickMerge('log')
})
});
function Parent(sources) {
const sinks = List(sources);
return {
...sinks,
onion: xs.of(() => [{ id: 0 }, { id: 1 }, { id: 2 }])
};
}
run(onionify(Parent), {
DOM: makeDOMDriver('#app'),
log: sink$ => {
sink$.addListener({
next: console.log
})
}
});
On multiple occasions I've triggered a stack overflow when a state emission causes multiple reducers to be emitted on the same turn of the event loop, which can cascade. In the example I just dealt with an HTTP response (with 125 results) causes a reducer emission to store the array of results in the state, which synchronously causes a collection to be created with 125 components each of which has a few startup reducers... The stack blew up on Chrome because there was some circular state issues... The exact details I haven't tracked down, but when I delayed the initial reducers from the each collection component, the issue went away. Similarly when I hacked in a delay into onionify.js
the issue goes away... like so...
sources[name] = new StateSource_1.StateSource(state$.compose(delay_1.default(1)), name);
Shouldn't state updates be async?
cycle-onionify (with RxJS) unsubscribes from the sink when there are no subscribers to the state$ and then restarts when a component in the tree uses onionify again. This restarts the state$. Shouldn't the onion state never restart, or is this the proper behavior?
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.