diff --git a/docs/API.md b/docs/API.md index 039fd944..4afd520a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -71,6 +71,11 @@ const PureComponent = pure(BaseComponent) + [`componentFromProp()`](#componentfromprop) + [`nest()`](#nest) + [`hoistStatics()`](#hoiststatics) +* [Observable utilities](#observable-utilities) + + [`componentFromStream()`](#componentfromstream) + + [`mapPropsStream()`](#mappropsstream) + + [`createEventHandler()`](#createEventHandler) + + [`setObservableConfig()`](#setobservableconfig) ## Higher-order components @@ -630,3 +635,185 @@ hoistStatics(hoc: HigherOrderComponent): HigherOrderComponent ``` Augments a higher-order component so that when used, it copies static properties from the base component to the new component. This is helpful when using Recompose with libraries like Relay. + +## Observable utilities + +It turns out that much of the React Component API can be expressed in terms of observables: + +- Instead of `setState()`, combine multiple streams together. +- Instead of `getInitialState()`, use `startWith()` or `concat()`. +- Instead of `shouldComponentUpdate()`, use `distinctUntilChanged()`, `debounce()`, etc. + +Other benefits include: + +- No distinction between state and props – everything is an stream. +- No need to worry about unsubscribing from event listeners. Subscriptions are handled for you. +- Sideways data loading is trivial – just combine the props stream with an external stream. +- Access to an ecosystem of observable libraries, such as RxJS. + + +**Recompose's observable utilities can be configured to work with any observable or stream-like library. See [`setObservableConfig()`](#setobservableconfig) below for details.** + +### `componentFromStream()` + +```js +componentFromStream( + propsToReactNode: (props$: Observable) => Observable +): ReactComponent +``` + +Creates a React component by mapping an observable stream of props to a stream of React nodes (vdom). + +You can think of `propsToReactNode` as a function `f` such that + +```js +const vdom$ = f(props$) +``` + +where `props$` is a stream of props and `vdom$` is a stream of React nodes. This formulation similar to the popular notion of React views as a function, often communicated as + +``` +v = f(d) +``` + +Example: + +```js +const Counter = componentFromStream(props$ => { + const { handler: increment, stream: increment$ } = createEventHandler() + const { handler: decrement, stream: decrement$ } = createEventHandler() + const count$ = Observable.merge( + increment$.mapTo(1), + decrement$.mapTo(-1) + ) + .startWith(0) + .scan((count, n) => count + n, 0) + + return props$.combineLatest( + count$, + (props, count) => +
+ Count: {count} + + +
+ ) +}) +``` + +### `mapPropsStream()` + +```js +mapPropsStream( + ownerPropsToChildProps: (props$: Observable) => Observable, + BaseComponent: ReactElementType +): ReactComponent +``` + +A higher-order component version of `componentFromStream()` — accepts a function that maps an observable stream of owner props to a stream of child props, rather than directly to a stream of React nodes. The child props are then passed to a base component. + +You may want to use this version to interoperate with other Recompose higher-order component helpers. + +```js +const enhance = mapPropsStream(props$ => { + const timeElapsed$ = Observable.interval(1000).pluck('value') + props$.combineLatest(timeElapsed$, (props, timeElapsed) => ({ + ...props, + timeElapsed + })) +}) + +const Timer = enhance(({ timeElapsed }) => +
Time elapsed: {timeElapsed}
+) +``` + +### `createEventHandler()` + +```js +createEventHandler(): { + handler: (value: T) => void + stream: Observable, +} +``` + +Returns an object with properties `handler` and `stream`. `stream` is an observable sequence, and `handler` is a function that pushes new values onto the sequence. Useful for creating event handlers like `onClick`. + +### `setObservableConfig()` + +```js +setObservableConfig({ + fromESObservable: ?(observable: Observable) => Stream, + toESObservable: ?(stream: Stream) => Observable +}) +``` + +Observables in Recompose are plain objects that conform to the [ES Observable proposal](https://github.com/zenparsing/es-observable). Usually, you'll want to use them alongside an observable library like RxJS so that you have access to its suite of operators. By default, this requires you to convert the observables provided by Recompose before applying any transforms: + +```js +mapPropsStream($props => { + const $rxjsProps = Rx.Observable.from(props$) + // ...now you can use map, filter, scan, etc. + return $transformedProps +}) +``` + +This quickly becomes tedious. Rather than performing this transform for each stream individually, `setObservableConfig()` sets a global observable transform that is applied automatically. + +```js +import Rx from 'rxjs' +import { setObservableConfig } from 'recompose' + +setObservableConfig({ + // Converts a plain ES observable to an RxJS 5 observable + fromESObservable: Rx.Observable.from +}) +``` + +In addition to `fromESObservable`, the config object also accepts `toESObservable`, which converts a stream back into an ES observable. Because RxJS 5 observables already conform to the ES observable spec, `toESObservable` is not necessary in the above example. However, it is required for libraries like RxJS 4 or xstream, whose streams do not conform to the ES observable spec. + +Fortunately, you likely don't need to worry about how to configure Recompose for your favorite stream library, because Recompose provides drop-in configuration for you. + +**Note: The following configuration modules are not included in the main export. You must import them individually, as shown in the examples.** + +#### RxJS + +```js +import rxjsconfig from 'recompose/rxjsObservableConfig' +setObservableConfig(rxjsconfig) +``` + +#### RxJS 4 (legacy) + +```js +import rxjs4config from 'recompose/rxjs4ObservableConfig' +setObservableConfig(rxjs4config) +``` + +#### most + +```js +import mostConfig from 'recompose/mostObservableConfig' +setObservableConfig(mostConfig) +``` + +#### xstream + +```js +import xstreamConfig from 'recompose/xstreamObservableConfig' +setObservableConfig(xstreamConfig) +``` + +#### Bacon + +```js +import baconConfig from 'recompose/baconObservableConfig' +setObservableConfig(baconConfig) +``` + +#### Kefir + +```js +import kefirConfig from 'recompose/kefirObservableConfig' +setObservableConfig(kefirConfig) +``` diff --git a/package.json b/package.json index 9ca283c1..5b2777f8 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "postinstall": "babel-node scripts/installNestedPackageDeps.js" }, "devDependencies": { - "ava": "git://github.com/avajs/ava.git#54e11064841c0169a88324f3daeda396d3e8889f", + "ava": "^0.15.2", "babel-cli": "^6.5.1", "babel-core": "^6.5.2", "babel-eslint": "^6.0.4", @@ -46,6 +46,7 @@ "babel-plugin-transform-react-jsx": "^6.8.0", "babel-plugin-transform-regenerator": "^6.8.0", "babel-polyfill": "^6.5.0", + "baconjs": "^0.7.84", "chai": "^3.3.0", "chalk": "^1.1.1", "change-case": "^2.3.1", @@ -59,15 +60,20 @@ "globby": "^4.0.0", "gzip-size-cli": "^1.0.0", "jsdom": "^8.4.0", + "kefir": "^3.2.3", + "most": "^0.19.7", "nyc": "^6.4.0", "react": "^15.0.0", "react-addons-test-utils": "^15.0.0", "react-dom": "^15.0.0", "readline-sync": "^1.2.21", "rimraf": "^2.4.3", + "rx": "^4.1.0", + "rxjs": "^5.0.0-beta.9", "shelljs": "^0.6.0", "sinon": "^1.17.1", - "webpack": "^1.12.0" + "webpack": "^1.12.0", + "xstream": "^5.0.5" }, "devEngines": { "node": "5.x", diff --git a/src/packages/recompose/__tests__/componentFromStream-test.js b/src/packages/recompose/__tests__/componentFromStream-test.js new file mode 100644 index 00000000..be546138 --- /dev/null +++ b/src/packages/recompose/__tests__/componentFromStream-test.js @@ -0,0 +1,64 @@ +import test from 'ava' +import React from 'react' +import { mount } from 'enzyme' +import { Observable, Subject } from 'rxjs' +import setObservableConfig from '../setObservableConfig' +import rxjsConfig from '../rxjsObservableConfig' +import componentFromStream from '../componentFromStream' + +setObservableConfig(rxjsConfig) + +test('componentFromStream creates a component from a prop stream transformation', t => { + const Double = componentFromStream(props$ => + props$.map(({ n }) =>
{n * 2}
) + ) + const wrapper = mount() + const div = wrapper.find('div') + t.is(div.text(), '224') + wrapper.setProps({ n: 358 }) + t.is(div.text(), '716') +}) + +test('componentFromStream unsubscribes from stream before unmounting', t => { + let subscriptions = 0 + const vdom$ = new Observable(observer => { + subscriptions += 1 + observer.next(
) + return { + unsubscribe() { + subscriptions -= 1 + } + } + }) + const Div = componentFromStream(() => vdom$) + const wrapper = mount(
) + t.is(subscriptions, 1) + wrapper.unmount() + t.is(subscriptions, 0) +}) + +test('componentFromStream renders nothing until the stream emits a value', t => { + const vdom$ = new Subject() + const Div = componentFromStream(() => vdom$.mapTo(
)) + const wrapper = mount(
) + t.is(wrapper.find('div').length, 0) + vdom$.next() + t.is(wrapper.find('div').length, 1) +}) + +test('handler multiple observers of props stream', t => { + const Div = componentFromStream(props$ => + // Adds three observers to props stream + props$.combineLatest( + props$, props$, + props1 =>
+ ) + ) + + const wrapper = mount(
) + const div = wrapper.find('div') + + t.is(div.prop('value'), 1) + wrapper.setProps({ value: 2 }) + t.is(div.prop('value'), 2) +}) diff --git a/src/packages/rx-recompose/__tests__/createEventHandler-test.js b/src/packages/recompose/__tests__/createEventHandler-test.js similarity index 54% rename from src/packages/rx-recompose/__tests__/createEventHandler-test.js rename to src/packages/recompose/__tests__/createEventHandler-test.js index 9335052b..35e748e8 100644 --- a/src/packages/rx-recompose/__tests__/createEventHandler-test.js +++ b/src/packages/recompose/__tests__/createEventHandler-test.js @@ -1,16 +1,16 @@ import test from 'ava' import { createEventHandler } from '../' -test('createEventHandler creates a subject that broadcasts new values when called as a function', t => { +test('createEventHandler creates an event handler and a corresponding stream', t => { const result = [] const { stream, handler } = createEventHandler() - const subscription = stream.subscribe(v => result.push(v)) + const subscription = stream.subscribe({ next: v => result.push(v) }) handler(1) handler(2) handler(3) - subscription.dispose() + subscription.unsubscribe() t.deepEqual(result, [1, 2, 3]) }) @@ -18,15 +18,15 @@ test('handles multiple subscribers', t => { const result1 = [] const result2 = [] const { handler, stream } = createEventHandler() - const subscription1 = stream.subscribe(v => result1.push(v)) - const subscription2 = stream.subscribe(v => result2.push(v)) + const subscription1 = stream.subscribe({ next: v => result1.push(v) }) + const subscription2 = stream.subscribe({ next: v => result2.push(v) }) handler(1) handler(2) handler(3) - subscription1.dispose() - subscription2.dispose() + subscription1.unsubscribe() + subscription2.unsubscribe() t.deepEqual(result1, [1, 2, 3]) t.deepEqual(result2, [1, 2, 3]) diff --git a/src/packages/recompose/__tests__/mapPropsStream-test.js b/src/packages/recompose/__tests__/mapPropsStream-test.js new file mode 100644 index 00000000..a5166fea --- /dev/null +++ b/src/packages/recompose/__tests__/mapPropsStream-test.js @@ -0,0 +1,20 @@ +import test from 'ava' +import React from 'react' +import setObservableConfig from '../setObservableConfig' +import rxjsConfig from '../rxjsObservableConfig' +import { mount } from 'enzyme' +import { mapPropsStream } from '../' + +setObservableConfig(rxjsConfig) + +// Most of mapPropsStream's functionality is covered by componentFromStream +test('mapPropsStream creates a higher-order component from a stream', t => { + const Double = mapPropsStream(props$ => + props$.map(({ n }) => ({ children: n * 2 })) + )('div') + const wrapper = mount() + const div = wrapper.find('div') + t.is(div.text(), '224') + wrapper.setProps({ n: 358 }) + t.is(div.text(), '716') +}) diff --git a/src/packages/recompose/__tests__/setObservableConfig-test.js b/src/packages/recompose/__tests__/setObservableConfig-test.js new file mode 100644 index 00000000..456d7e85 --- /dev/null +++ b/src/packages/recompose/__tests__/setObservableConfig-test.js @@ -0,0 +1,62 @@ +import test from 'ava' +import React from 'react' +import { mount } from 'enzyme' +import rxjs5Config from '../rxjsObservableConfig' +import rxjs4Config from '../rxjs4ObservableConfig' +import mostConfig from '../mostObservableConfig' +import xstreamConfig from '../xstreamObservableConfig' +import baconConfig from '../baconObservableConfig' +import kefirConfig from '../kefirObservableConfig' +import setObservableConfig from '../setObservableConfig' +import componentFromStream from '../componentFromStream' + +const testTransform = (t, transform) => { + const Double = componentFromStream(transform) + const wrapper = mount() + const div = wrapper.find('div') + t.is(div.text(), '224') + wrapper.setProps({ n: 358 }) + t.is(div.text(), '716') +} + +test('works with RxJS 5', t => { + setObservableConfig(rxjs5Config) + testTransform(t, props$ => + props$.map(({ n }) =>
{n * 2}
) + ) +}) + +test('works with RxJS 4', t => { + setObservableConfig(rxjs4Config) + testTransform(t, props$ => + props$.map(({ n }) =>
{n * 2}
) + ) +}) + +test('works with most', t => { + setObservableConfig(mostConfig) + testTransform(t, props$ => + props$.map(({ n }) =>
{n * 2}
) + ) +}) + +test('works with xstream', t => { + setObservableConfig(xstreamConfig) + testTransform(t, props$ => + props$.map(({ n }) =>
{n * 2}
) + ) +}) + +test('works with bacon', t => { + setObservableConfig(baconConfig) + testTransform(t, props$ => + props$.map(({ n }) =>
{n * 2}
) + ) +}) + +test('works with kefir', t => { + setObservableConfig(kefirConfig) + testTransform(t, props$ => + props$.map(({ n }) =>
{n * 2}
) + ) +}) diff --git a/src/packages/recompose/baconObservableConfig.js b/src/packages/recompose/baconObservableConfig.js new file mode 100644 index 00000000..b128c9c9 --- /dev/null +++ b/src/packages/recompose/baconObservableConfig.js @@ -0,0 +1,32 @@ +import $$observable from 'symbol-observable' +import Bacon from 'baconjs' + +const config = { + fromESObservable: observable => Bacon.fromBinder(sink => { + const { unsubscribe } = observable.subscribe({ + next: val => sink(new Bacon.Next(val)), + error: err => sink(new Bacon.Error(err)), + complete: () => sink(new Bacon.End()) + }) + return unsubscribe + }), + toESObservable: stream => ({ + subscribe: observer => { + const unsubscribe = stream.subscribe(event => { + if (event.hasValue()) { + observer.next(event.value()) + } else if (event.isError()) { + observer.error(event.error) + } else if (event.isEnd()) { + observer.complete() + } + }) + return { unsubscribe } + }, + [$$observable]() { + return this + } + }) +} + +export default config diff --git a/src/packages/recompose/componentFromStream.js b/src/packages/recompose/componentFromStream.js new file mode 100644 index 00000000..26a093b7 --- /dev/null +++ b/src/packages/recompose/componentFromStream.js @@ -0,0 +1,57 @@ +import { Component } from 'react' +import { createChangeEmitter } from 'change-emitter' +import $$observable from 'symbol-observable' +import { fromESObservable, toESObservable } from './setObservableConfig' + +const componentFromStream = propsToVdom => + class ComponentFromStream extends Component { + state = { vdom: null }; + + propsEmitter = createChangeEmitter(); + + // Stream of props + props$ = fromESObservable({ + subscribe: observer => { + const unsubscribe = this.propsEmitter.listen( + props => observer.next(props) + ) + return { unsubscribe } + }, + [$$observable]() { + return this + } + }); + + // Stream of vdom + vdom$ = toESObservable(propsToVdom(this.props$)); + + componentWillMount() { + // Subscribe to child prop changes so we know when to re-render + this.subscription = this.vdom$.subscribe({ + next: vdom => { + this.setState({ vdom }) + } + }) + this.propsEmitter.emit(this.props) + } + + componentWillReceiveProps(nextProps) { + // Receive new props from the owner + this.propsEmitter.emit(nextProps) + } + + shouldComponentUpdate(nextProps, nextState) { + return nextState.vdom !== this.state.vdom + } + + componentWillUnmount() { + // Clean-up subscription before un-mounting + this.subscription.unsubscribe() + } + + render() { + return this.state.vdom + } + } + +export default componentFromStream diff --git a/src/packages/recompose/createEventHandler.js b/src/packages/recompose/createEventHandler.js new file mode 100644 index 00000000..39da75d2 --- /dev/null +++ b/src/packages/recompose/createEventHandler.js @@ -0,0 +1,22 @@ +import $$observable from 'symbol-observable' +import { createChangeEmitter } from 'change-emitter' +import { fromESObservable } from './setObservableConfig' + +const createEventHandler = () => { + const emitter = createChangeEmitter() + const stream = fromESObservable({ + subscribe(observer) { + const unsubscribe = emitter.listen(value => observer.next(value)) + return { unsubscribe } + }, + [$$observable]() { + return this + } + }) + return { + handler: emitter.emit, + stream + } +} + +export default createEventHandler diff --git a/src/packages/recompose/index.js b/src/packages/recompose/index.js index 258fe634..f6b685a3 100644 --- a/src/packages/recompose/index.js +++ b/src/packages/recompose/index.js @@ -40,3 +40,9 @@ export createSink from './createSink' export componentFromProp from './componentFromProp' export nest from './nest' export hoistStatics from './hoistStatics' + +// Observable helpers +export componentFromStream from './componentFromStream' +export mapPropsStream from './mapPropsStream' +export createEventHandler from './createEventHandler' +export setObservableConfig from './setObservableConfig' diff --git a/src/packages/recompose/kefirObservableConfig.js b/src/packages/recompose/kefirObservableConfig.js new file mode 100644 index 00000000..094a6569 --- /dev/null +++ b/src/packages/recompose/kefirObservableConfig.js @@ -0,0 +1,8 @@ +import Kefir from 'kefir' + +const config = { + fromESObservable: Kefir.fromESObservable, + toESObservable: stream => stream.toESObservable() +} + +export default config diff --git a/src/packages/recompose/mapPropsStream.js b/src/packages/recompose/mapPropsStream.js new file mode 100644 index 00000000..061a54de --- /dev/null +++ b/src/packages/recompose/mapPropsStream.js @@ -0,0 +1,24 @@ +import $$observable from 'symbol-observable' +import createEagerFactory from './createEagerFactory' +import createHelper from './createHelper' +import componentFromStream from './componentFromStream' +import { toESObservable } from './setObservableConfig' + +const mapPropsStream = transform => BaseComponent => { + const factory = createEagerFactory(BaseComponent) + return componentFromStream(ownerProps$ => ({ + subscribe(observer) { + const subscription = toESObservable(transform(ownerProps$)).subscribe({ + next: childProps => observer.next(factory(childProps)) + }) + return { + unsubscribe: () => subscription.unsubscribe() + } + }, + [$$observable]() { + return this + } + })) +} + +export default createHelper(mapPropsStream, 'mapPropsStream') diff --git a/src/packages/recompose/mostObservableConfig.js b/src/packages/recompose/mostObservableConfig.js new file mode 100644 index 00000000..21b5fa70 --- /dev/null +++ b/src/packages/recompose/mostObservableConfig.js @@ -0,0 +1,7 @@ +import most from 'most' + +const config = { + fromESObservable: most.from +} + +export default config diff --git a/src/packages/recompose/package.json b/src/packages/recompose/package.json index b522dc61..05b4ed43 100644 --- a/src/packages/recompose/package.json +++ b/src/packages/recompose/package.json @@ -10,8 +10,10 @@ "composition" ], "dependencies": { + "change-emitter": "^0.1.2", "fbjs": "^0.8.1", - "hoist-non-react-statics": "^1.0.0" + "hoist-non-react-statics": "^1.0.0", + "symbol-observable": "^0.2.4" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0" diff --git a/src/packages/recompose/rxjs4ObservableConfig.js b/src/packages/recompose/rxjs4ObservableConfig.js new file mode 100644 index 00000000..7bdfc0b0 --- /dev/null +++ b/src/packages/recompose/rxjs4ObservableConfig.js @@ -0,0 +1,28 @@ +import $$observable from 'symbol-observable' +import Rx from 'rx' + +const config = { + fromESObservable: observable => Rx.Observable.create(observer => { + const { unsubscribe } = observable.subscribe({ + next: val => observer.onNext(val), + error: error => observer.onError(error), + complete: () => observer.onCompleted() + }) + return unsubscribe + }), + toESObservable: rxObservable => ({ + subscribe: observer => { + const { dispose } = rxObservable.subscribe( + val => observer.next(val), + error => observer.error(error), + () => observer.complete() + ) + return { unsubscribe: dispose } + }, + [$$observable]() { + return this + } + }) +} + +export default config diff --git a/src/packages/recompose/rxjsObservableConfig.js b/src/packages/recompose/rxjsObservableConfig.js new file mode 100644 index 00000000..d88122ca --- /dev/null +++ b/src/packages/recompose/rxjsObservableConfig.js @@ -0,0 +1,7 @@ +import Rx from 'rxjs' + +const config = { + fromESObservable: Rx.Observable.from +} + +export default config diff --git a/src/packages/recompose/setObservableConfig.js b/src/packages/recompose/setObservableConfig.js new file mode 100644 index 00000000..6d6524ce --- /dev/null +++ b/src/packages/recompose/setObservableConfig.js @@ -0,0 +1,20 @@ +let config = { + fromESObservable: null, + toESObservable: null +} + +export const fromESObservable = observable => + typeof config.fromESObservable === 'function' + ? config.fromESObservable(observable) + : observable + +export const toESObservable = stream => + typeof config.toESObservable === 'function' + ? config.toESObservable(stream) + : stream + +const configureObservable = c => { + config = c +} + +export default configureObservable diff --git a/src/packages/recompose/xstreamObservableConfig.js b/src/packages/recompose/xstreamObservableConfig.js new file mode 100644 index 00000000..7fe3b58d --- /dev/null +++ b/src/packages/recompose/xstreamObservableConfig.js @@ -0,0 +1,34 @@ +import $$observable from 'symbol-observable' +import xstream from 'xstream' + +const noop = () => {} + +const config = { + fromESObservable: observable => xstream.create({ + subscription: null, + start(listener) { + this.subscription = observable.subscribe(listener) + }, + stop() { + this.subscription.unsubscribe() + } + }), + toESObservable: stream => ({ + subscribe: observer => { + const listener = { + next: observer.next || noop, + error: observer.error || noop, + complete: observer.complete || noop + } + stream.addListener(listener) + return { + unsubscribe: () => stream.removeListener(listener) + } + }, + [$$observable]() { + return this + } + }) +} + +export default config diff --git a/src/packages/rx-recompose/.npmignore b/src/packages/rx-recompose/.npmignore deleted file mode 100644 index 2e745645..00000000 --- a/src/packages/rx-recompose/.npmignore +++ /dev/null @@ -1 +0,0 @@ -/**/__tests__ diff --git a/src/packages/rx-recompose/README.md b/src/packages/rx-recompose/README.md deleted file mode 100644 index 0d9b8342..00000000 --- a/src/packages/rx-recompose/README.md +++ /dev/null @@ -1,115 +0,0 @@ -rx-recompose -============ - -[![npm version](https://img.shields.io/npm/v/rx-recompose.svg?style=flat-square)](https://www.npmjs.com/package/rx-recompose) - -RxJS utilities for [Recompose](https://github.com/acdlite/recompose). - -``` -npm install --save rx-recompose -``` - -It turns out that much of the React Component API can be expressed in terms of observables: - -- Instead of `setState()`, combine multiple streams together. -- Instead of `getInitialState()`, use `startWith()` or `concat()`. -- Instead of `shouldComponentUpdate()`, use `distinctUntilChanged()`, `debounce()`, etc. - -Other benefits include: - -- No distinction between state and props – everything is an stream. -- No need to worry about unsubscribing from event listeners. Subscriptions are handled for you. -- Sideways data loading is trivial – just combine the props stream with an external stream. -- Access to the full ecosystem of RxJS libraries. - -## API - -### `createComponent()` - -```js -createComponent( - propsToReactNode: (props$: Observable) => Observable -): ReactComponent -``` - -Creates a React component by mapping an observable stream of props to a stream of React nodes (vdom). - -You can think of `propsToReactNode` as a function `f` such that - -```js -const vdom$ = f(props$) -``` - -where `props$` is a stream of props and `vdom$` is a stream of React nodes. This formulation similar to the popular notion of React views as a function, often communicated as - -``` -v = f(d) -``` - -See below for a full example. - -### `mapPropsStream()` - -```js -mapPropsStream( - ownerPropsToChildProps: (props$: Observable) => Observable, - BaseComponent: ReactElementType -): ReactComponent -``` - -A higher-order component version of `createComponent()` — accepts a function that maps an observable stream of owner props to a stream of child props, rather than directly to a stream of React nodes. The child props are then passed to a base component. - -You may want to use this version to interoperate with other Recompose higher-order component helpers. - -```js -const enhance = mapPropsStream(props$ => { - const timeElapsed$ = Observable.interval(1000).pluck('value') - props$.combineLatest(timeElapsed$, (props, timeElapsed) => ({ - ...props, - timeElapsed - })) -}) - -const Timer = enhance(({ timeElapsed }) => -
Time elapsed: {timeElapsed}
-) -``` - -### `createEventHandler()` - -```js -createEventHandler(): { - handler: (value: T) => void - stream: Observable, -} -``` - -Returns an object with properties `handler` and `stream`. `stream` is an observable sequence, and `handler` is a function that pushes new values onto the sequence. (This is akin to mailboxes in Elm.) Useful for creating event handlers like `onClick`. - -## Example - -```js -import { createComponent, createEventHandler } from 'rx-recompose' -import { Observable } from 'rx' - -const Counter = createComponent(props$ => { - const { handler: increment, stream: increment$ } = createEventHandler() - const { handler: decrement, stream: decrement$ } = createEventHandler() - const count$ = Observable.merge( - increment$.map(() => 1), - decrement$.map(() => -1) - ) - .startWith(0) - .scan((count, n) => count + n, 0) - - return props$.combineLatest( - count$, - (props, count) => -
- Count: {count} - - -
- ) -}) -``` diff --git a/src/packages/rx-recompose/VERSION b/src/packages/rx-recompose/VERSION deleted file mode 100644 index b6160487..00000000 --- a/src/packages/rx-recompose/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.6.2 diff --git a/src/packages/rx-recompose/__tests__/mapPropsStream-test.js b/src/packages/rx-recompose/__tests__/mapPropsStream-test.js deleted file mode 100644 index 3a7d6250..00000000 --- a/src/packages/rx-recompose/__tests__/mapPropsStream-test.js +++ /dev/null @@ -1,138 +0,0 @@ -import test from 'ava' -import React from 'react' -import { Observable, Subject } from 'rx' -import { withState, compose, branch } from 'recompose' -import { mapPropsStream, createEventHandler } from '../' -import { mount, shallow } from 'enzyme' - -const identity = t => t - -test('mapPropsStream maps a stream of owner props to a stream of child props', t => { - const SmartButton = mapPropsStream(props$ => { - const { handler: onClick, stream: increment$ } = createEventHandler() - const count$ = increment$ - .startWith(0) - .scan(total => total + 1) - - return Observable.combineLatest(props$, count$, (props, count) => ({ - ...props, - onClick, - count - })) - })('button') - - t.is(SmartButton.displayName, 'mapPropsStream(button)') - - const button = mount().find('button') - - button.simulate('click') - button.simulate('click') - button.simulate('click') - - t.is(button.prop('count'), 3) - t.is(button.prop('pass'), 'through') -}) - -test('mapPropsStream works on initial render', t => { - const SmartButton = mapPropsStream(props$ => { - const { handler: onClick, stream: increment$ } = createEventHandler() - const count$ = increment$ - .startWith(0) - .scan(total => total + 1) - - return Observable.combineLatest(props$, count$, (props, count) => ({ - ...props, - onClick, - count - })) - })('button') - - const button = shallow().find('button') - - t.is(button.prop('count'), 0) - t.is(button.prop('pass'), 'through') -}) - -test('mapPropsStream receives prop updates', t => { - const SmartButton = mapPropsStream(props$ => { - const { handler: onClick, stream: increment$ } = createEventHandler() - const count$ = increment$ - .startWith(0) - .scan(total => total + 1) - - return Observable.combineLatest(props$, count$, (props, count) => ({ - ...props, - onClick, - count - })) - })('button') - - const Container = withState('label', 'updateLabel', 'Count')(SmartButton) - - const button = mount().find('button') - const { updateLabel } = button.props() - - t.is(button.prop('label'), 'Count') - updateLabel('Current count') - t.is(button.prop('label'), 'Current count') -}) - -test('mapPropsStream unsubscribes before unmounting', t => { - const { handler: onClick, stream: increment$ } = createEventHandler() - let count = 0 - - const Container = compose( - withState('observe', 'updateObserve', false), - branch( - props => props.observe, - mapPropsStream(() => - increment$ - .do(() => { count += 1 }) - .map(() => ({})) - ), - identity - ) - )('div') - - const div = mount().find('div') - const { updateObserve } = div.props() - - t.is(count, 0) - updateObserve(true) // Mount component - onClick() - t.is(count, 1) - onClick() - t.is(count, 2) - updateObserve(false) // Unmount component - onClick() - t.is(count, 2) -}) - -test('mapPropsStream renders null until stream of props emits value', t => { - const props$ = new Subject() - const Container = mapPropsStream(() => props$)('div') - const wrapper = mount() - - t.false(wrapper.some('div')) - props$.onNext({ foo: 'bar' }) - t.is(wrapper.find('div').prop('foo'), 'bar') -}) - - -test('handler multiple observers of props stream', t => { - const Container = mapPropsStream(props$ => - // Adds three observers to props stream - props$.combineLatest( - props$, props$, - props1 => props1 - ) - )('div') - - const wrapper = mount() - const div = wrapper.find('div') - - t.is(div.prop('value'), 1) - // Push onto props stream - wrapper.setProps({ value: 2 }) - t.is(div.prop('value'), 2) -}) diff --git a/src/packages/rx-recompose/createComponent.js b/src/packages/rx-recompose/createComponent.js deleted file mode 100644 index a7c49a3a..00000000 --- a/src/packages/rx-recompose/createComponent.js +++ /dev/null @@ -1,64 +0,0 @@ -import { Component } from 'react' -import { Observable } from 'rx' -import { createChangeEmitter } from 'change-emitter' - -const createComponent = propsToVdom => - class RxComponent extends Component { - state = {}; - - propsEmitter = createChangeEmitter(); - - // Stream of props - props$ = Observable.create(observer => - this.propsEmitter.listen(props => observer.onNext(props)) - ); - - // Stream of vdom - vdom$ = propsToVdom(this.props$); - - didReceiveVdom = false; - - // Keep track of whether the component has mounted - componentHasMounted = false; - - componentWillMount() { - // Subscribe to child prop changes so we know when to re-render - this.subscription = this.vdom$.subscribe( - vdom => { - this.didReceiveVdom = true - if (!this.componentHasMounted) { - this.state = { vdom } - return - } - this.setState({ vdom }) - } - ) - - this.propsEmitter.emit(this.props) - } - - componentDidMount() { - this.componentHasMounted = true - } - - componentWillReceiveProps(nextProps) { - // Receive new props from the owner - this.propsEmitter.emit(nextProps) - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.vdom !== this.state.vdom - } - - componentWillUnmount() { - // Clean-up subscription before un-mounting - this.subscription.dispose() - } - - render() { - if (!this.didReceiveVdom) return null - return this.state.vdom - } - } - -export default createComponent diff --git a/src/packages/rx-recompose/createEventHandler.js b/src/packages/rx-recompose/createEventHandler.js deleted file mode 100644 index 7e4775e0..00000000 --- a/src/packages/rx-recompose/createEventHandler.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Observable } from 'rx' -import { createChangeEmitter } from 'change-emitter' - -const createEventHandler = () => { - const emitter = createChangeEmitter() - const stream = Observable.create(observer => - emitter.listen(value => observer.onNext(value)) - ) - return { - handler: emitter.emit, - stream - } -} - -export default createEventHandler diff --git a/src/packages/rx-recompose/index.js b/src/packages/rx-recompose/index.js deleted file mode 100644 index 553931ca..00000000 --- a/src/packages/rx-recompose/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export createComponent from './createComponent' -export mapPropsStream from './mapPropsStream' -export createEventHandler from './createEventHandler' diff --git a/src/packages/rx-recompose/mapPropsStream.js b/src/packages/rx-recompose/mapPropsStream.js deleted file mode 100644 index 197fd9b9..00000000 --- a/src/packages/rx-recompose/mapPropsStream.js +++ /dev/null @@ -1,18 +0,0 @@ -import createElement from 'recompose/createElement' -import createHelper from 'recompose/createHelper' -import createComponent from './createComponent' -import { Observable } from 'rx' - -const mapPropsStream = ownerPropsToChildProps => BaseComponent => - createComponent(ownerProps$ => - Observable.create(observer => { - const subscription = ownerPropsToChildProps(ownerProps$).subscribe( - childProps => observer.next( - createElement(BaseComponent, childProps) - ) - ) - return () => subscription.dispose() - }) - ) - -export default createHelper(mapPropsStream, 'mapPropsStream') diff --git a/src/packages/rx-recompose/package.json b/src/packages/rx-recompose/package.json deleted file mode 100644 index b8a95649..00000000 --- a/src/packages/rx-recompose/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "description": "RxJS utilities for React and Recompose.", - "keywords": [ - "recompose", - "rxjs", - "rx", - "react", - "observable", - "reactive", - "higher-order", - "components", - "microcomponentization", - "toolkit", - "utilities", - "composition" - ], - "dependencies": { - "change-emitter": "^0.1.2", - "lodash": "^4.0.0" - }, - "peerDependencies": { - "recompose": "^0.17.0", - "react": "^0.14.0 || ^15.0.0", - "rx": "^4.0.0" - }, - "devDependencies": { - "recompose": "0.19.0", - "rx": "^4.0.0" - } -}