Giter VIP home page Giter VIP logo

cycle-onionify's Introduction

👋 Hi! I'm Andre Staltz

I work with JavaScript, TypeScript, peer-to-peer networks, user interfaces, reactive programming, and React Native. I have published 300+ libraries on npm, such as Cycle.js, Callbags, SSB utilities, React Native utilities, and others. My latest project was Manyverse, an open source app for the peer-to-peer social network SSB. At the moment I am fully booked as an independent consultant.

As a writer, my blog has articles on open source, the future of the internet, commentary on cybereconomics, and peer-to-peer systems. This gist I wrote is very popular.

cycle-onionify's People

Contributors

abaco avatar bloodyknuckles avatar cluelessjoe avatar davidskuza avatar goodmind avatar jvanbruegge avatar ntilwalli avatar staltz avatar stevealee avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

cycle-onionify's Issues

Conventional place for non-onion reduced state?

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

How to share state between sibling components

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?

Collection API and pickCombine/pickMerge

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:

  • Solved thanks to a PR by @abaco. A child component may get double updates since it is onionified as well as the parent is. When the child component sends out a reducer to the onion sink, it will update its onion source state, but also the parent onion source state. We need to find a way to make the child avoid the second update since its redundant.
  • Update or replace the test "should work with an isolated list child with a default reducer"
  • How do we allow lenses on individual items, like we can do e.g. in example 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?
  • How to avoid fragility to object equality issues? (read the thread)
  • How to keep the feature where we can pass a channel name instead of "onion"
  • This change expects item state to have a unique key. Do we still want to allow keyless items in a list?

Checklist before final release:

  • Rewrite official cycle examples
  • Rewrite todomvc
  • Rewrite matrixmultiplication
  • Write JSDocs for asCollection, pickCombine, pickMerge
  • asCollection to support state$ when it's a stream of objects
  • Implement CollectionSource
  • Rename asCollection to toCollection
  • Gather more feedback
  • Fix bug described by ntilwalli
  • Add tests for Cycle Unified
  • Check issue ntilwalli raised about isolateEach
  • Fix issue ntilwalli raised about RxJS support
  • Check issue ntilwalli raised about using toCollection without wrapper List component
  • Check issue atomrc raised about toCollection()
  • Check issue jvanbruegge raised about initial state
  • Update readme.md docs
  • Publish

Add ES6 module build

onionify acutally causes a scope hoisting bailout for @cycle/isolate that would be fixed with a ES6 build

Immutable.js version

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! 😃

Rename lens getter/setter

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 to zoomIn / zoomOut to avoid confusion with the OO getter () => T and setter T => 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.

README doesn't mention state persistence

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

order of drivers has impact

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!

cannot compile typescript examples

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
[...]

Add mock-onionify

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

Help needed - MemoryStream.map not producing output

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!

Vocabulary: action vs intent

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

++

RangeError: Maximum call stack size exceeded

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;

State update to a falsy value is ignored in isolated component

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')
});

pickMerge seems to swallow events

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
    })
  }
});

Possible to not emit until default reducer gets run?

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.

Shouldn't state emissions be microtask queued?

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?

pickCombine fails when re-adding item with same key

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

"Reducer" term is not correct

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

onionify typings assume "onion" as key

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.

Proposal for dealing with shared/derived data

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

  • Better separation of concerns and reusability of components. In the new "mixed" examples, each list item sees the counter value as if it owned it.
  • Backwards compatibility.
  • Power. This is the first time I use partial.lenses, and so far I'm really impressed. It seems extremely powerful.

type MakeScopesFn does not exist but imported

import {Scope, Reducer, MakeScopesFn} from './lib/types';

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.

Onion state resets when there are no current state subscribers

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?

Onionify not working with RxJS

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;
}

Define Omit<T, K> properly?

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.

export type Omit<T, K extends keyof T> = any;

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:

  • The change requires the dependency of TypeScript 2.8+
    • currently depending on 2.5.x
  • It will possibly be a breaking change toward codebases with improperly typed onionified components
    • thus it'll require a major version bump

Emitting undefined automatically when a component's onion stream is unsubscribed

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?

Composability of Cycle add-ons as wrapper functions

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.

[Question] How to share data?

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.

emitting `xs.never` with pickCombine might be wrong

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

pickMerge throws error if child is not using sink

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

Isolation scope name problems

Problem 1

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) 

Problem 2

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)

List: repetative recreating of child components may be a problem

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

Get current state inside model and make a http request

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$
 };

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.