├── .gitignore ├── HISTORY.md ├── README.md ├── examples └── search │ ├── .meteor │ ├── .finished-upgraders │ ├── .gitignore │ ├── .id │ ├── packages │ ├── platforms │ ├── release │ └── versions │ ├── README.md │ ├── main.coffee │ └── packages │ ├── ccorcos:highland │ ├── ccorcos:react │ └── ccorcos:react-highland ├── package.js └── src ├── highland-extensions.js ├── meteor-highland.coffee └── react-highland.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | .versions -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/meteor-react-highland/d42c1409849a322137dfced0967ef222ea799d90/HISTORY.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + Highland + Meteor 2 | 3 | This package is an experiment using [Highland](http://highlandjs.org/) and [Metoer](https://www.meteor.com/) to reactively render with [React](https://facebook.github.io/react/). [Check out the example](/examples/search/main.coffee)! 4 | 5 | meteor add ccorcos:react-highland 6 | 7 | ### Motivation 8 | 9 | The one thing I didn't like about React is that I found myself passing functions between components all of the time. When I started experimenting more with [React for Meteor](https://github.com/ccorcos/meteor-react-utils), I realized that Meteor's `ReactiveVar`s can help to aleviate this pain. But eventually, I hit some frustrations. I had a search query in a `ReactiveVar` that I wanted to debounce before creating a Meteor subscription. This could be done with another `ReactiveVar` but I recalled a more elegant solution with observable streams that I learned from [Jafar Husain's Netflix Javascript talk](https://www.youtube.com/watch?v=XRYN2xt11Ek). 10 | 11 | Long story short, this package is a result of trying to integrate React and Meteor using the Highland.js observable streams library. I'm pretty happy with the result. The following creates a stream of search queries from a ["controlled input"](https://facebook.github.io/react/docs/forms.html) and displays the results. 12 | 13 | 14 | ```coffee 15 | search = _() 16 | _.route '/', (params, queryParams) -> 17 | (div {}, 18 | (search.fork().map (x) -> 19 | (input { 20 | value: x, 21 | placeholder:'Search', 22 | onChange: (e) -> 23 | search.write(e.target.value) 24 | }) 25 | ) 26 | (div {}, 27 | (search.fork() 28 | .debounce(500) 29 | .through _.filterRepeats 30 | .through _.subscribe (query) -> 31 | if query.length >= 1 32 | Meteor.subscribe('search', query, 20) 33 | .through _.multi _.map, (query, ready) -> 34 | if query.length < 1 35 | false 36 | else if not ready 37 | (div {}, "loading...") 38 | else 39 | _.autorunStream -> 40 | filter = new RegExp(query, 'ig') 41 | posts = Posts.find({ 42 | title:filter 43 | }, { 44 | sort: { 45 | name: 1, 46 | date: -1 47 | }, 48 | limit: 20 49 | }).fetch() 50 | (div {}, 51 | do -> 52 | p = posts.map (post) -> 53 | (div {key:post._id}, post.title) 54 | if p.length is 0 55 | return "No results for '#{query}'" 56 | else 57 | return p 58 | ) 59 | .through _.switchOnNext 60 | ) 61 | ) 62 | ) 63 | .each (x) -> 64 | React.render(x, document.body) 65 | search.write('') 66 | ``` 67 | 68 | ## Highland + React 69 | 70 | This package allows you to pass highland streams of React components as children to React components. You can do this by passing a factory to the `React.streamable` function. For example, the following will counter of seconds. 71 | 72 | ```js 73 | div = React.streamable(React.DOM.div); 74 | span = React.streamable(React.DOM.span); 75 | stream = _(); 76 | React.render( 77 | div({}, "seconds: ", stream) 78 | , document.body); 79 | i = 0; 80 | stream.write(i); 81 | setInterval(function() { 82 | i++; 83 | stream.write(span({}, i.toString())); 84 | }, 1000) 85 | ``` 86 | ## Highland Extensions 87 | 88 | One extension that needs clarification is a concept of *parallel streams* which involve the `_.join` and `_.multi` functions. `_.join` takes a function that returns a stream, and that stream is run in parallel with the source stream. Whenever either stream changes, a new value is emitted with both values in an array. Perhaps its easier to understand from this example: 89 | 90 | ```js 91 | a = _() 92 | b = null 93 | a.through(_.join(function(x) { 94 | b = _() 95 | b.write(x*2) 96 | return b 97 | })).each(_.log) 98 | a.write(1) 99 | // => [1, 2] 100 | a.write(2) 101 | // => [2, 4] 102 | b.write(3) 103 | // => [2, 3] 104 | a.write(3) 105 | // => [3, 6] 106 | ``` 107 | 108 | `_.multi` is just a helper so that the parallel streams appear as arguments to a transform function as opposed to a single array argument. It takes two arguments, a transform function, and a function to run with that transform. For example: 109 | 110 | ```js 111 | a = _() 112 | b = null 113 | a.through(_.join(function(x) { 114 | b = _() 115 | b.write(x*2) 116 | return b 117 | })).through(_.multi(_.each, function(aValue, bValue) { 118 | console.log(aValue, bValue); 119 | })) 120 | a.write(1) 121 | // => 1 2 122 | a.write(2) 123 | // => 2 4 124 | b.write(3) 125 | // => 2 3 126 | a.write(3) 127 | // => 3 6 128 | ``` 129 | 130 | ## Highland + Meteor 131 | 132 | This package has a few stream functions to integrate Meteor's reactivity with Highland's streams. 133 | 134 | `_.autorunStream` will run a function in a reactive context and will write the returned value to a stream. For example, the following will return a stream of posts: 135 | 136 | ```js 137 | _.autorunStream(function() { 138 | return Posts.find().fetch() 139 | }); 140 | ``` 141 | 142 | `_.subscribe` will run a function that returns a subscription object. That subscription will be stopped whenever the stream ends or whenever a new value is written to the source stream. It also creates a stream of ready values and passes it on as a *parallel stream*. For example: 143 | 144 | ```js 145 | selectedPostIdStream = _() 146 | selectedPostIdStream.subscribe(function(postId){ 147 | return Meteor.subscribe('post', postId) 148 | }).through(_.multi(_.map, function(postId, ready) { 149 | if (ready) { 150 | var post = Posts.findOne(postId) 151 | return div({}, post.title) 152 | } else { 153 | return div({}, 'loading...') 154 | } 155 | })).map(function(x) { 156 | React.render(x, document.body) 157 | }) 158 | selectedPostIdStream.write("MmjwG9ELrbBfrFGPv") 159 | ``` 160 | 161 | `_.route` uses [`FlowRouter`](https://github.com/meteorhacks/flow-router/) to create a stream for your routes providing a nice entry point for your app. For example: 162 | 163 | ```js 164 | selectedPostIdStream = _() 165 | _.route('/post/:postId', function(params, queryParams) { 166 | return params.postId 167 | }).subscribe(function(postId){ 168 | return Meteor.subscribe('post', postId) 169 | }).through(_.multi(_.map, function(postId, ready) { 170 | if (ready) { 171 | var post = Posts.findOne(postId) 172 | return div({}, post.title) 173 | } else { 174 | return div({}, 'loading...') 175 | } 176 | })).map(function(x) { 177 | React.render(x, document.body) 178 | }) 179 | ``` 180 | 181 | #### Acknowledgements 182 | 183 | Thanks to [vqvu](https://github.com/vqvu) for all the help with Highland.js. -------------------------------------------------------------------------------- /examples/search/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | -------------------------------------------------------------------------------- /examples/search/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /examples/search/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | fh46ub1pkmvno11fsrmi 8 | -------------------------------------------------------------------------------- /examples/search/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-platform 8 | insecure 9 | ccorcos:react 10 | meteorhacks:flow-router 11 | anti:fake 12 | coffeescript 13 | ccorcos:highland 14 | ccorcos:react-highland 15 | -------------------------------------------------------------------------------- /examples/search/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /examples/search/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.0.3.2 2 | -------------------------------------------------------------------------------- /examples/search/.meteor/versions: -------------------------------------------------------------------------------- 1 | anti:fake@0.4.1 2 | application-configuration@1.0.4 3 | autoupdate@1.1.5 4 | base64@1.0.2 5 | binary-heap@1.0.2 6 | blaze@2.0.4 7 | blaze-tools@1.0.2 8 | boilerplate-generator@1.0.2 9 | callback-hook@1.0.2 10 | ccorcos:highland@0.0.1 11 | ccorcos:react@0.0.1 12 | ccorcos:react-highland@0.0.1 13 | check@1.0.4 14 | coffeescript@1.0.5 15 | ddp@1.0.14 16 | deps@1.0.6 17 | ejson@1.0.5 18 | fastclick@1.0.2 19 | follower-livedata@1.0.3 20 | geojson-utils@1.0.2 21 | html-tools@1.0.3 22 | htmljs@1.0.3 23 | http@1.0.10 24 | id-map@1.0.2 25 | insecure@1.0.2 26 | jquery@1.11.3 27 | json@1.0.2 28 | launch-screen@1.0.1 29 | livedata@1.0.12 30 | logging@1.0.6 31 | meteor@1.1.4 32 | meteor-platform@1.2.1 33 | meteorhacks:flow-router@1.0.2 34 | minifiers@1.1.3 35 | minimongo@1.0.6 36 | mobile-status-bar@1.0.2 37 | mongo@1.0.11 38 | observe-sequence@1.0.4 39 | ordered-dict@1.0.2 40 | random@1.0.2 41 | reactive-dict@1.0.5 42 | reactive-var@1.0.4 43 | reload@1.1.2 44 | retry@1.0.2 45 | routepolicy@1.0.4 46 | session@1.0.5 47 | spacebars@1.0.5 48 | spacebars-compiler@1.0.4 49 | templating@1.0.11 50 | tracker@1.0.5 51 | ui@1.0.5 52 | underscore@1.0.2 53 | url@1.0.3 54 | webapp@1.1.6 55 | webapp-hashing@1.0.2 56 | -------------------------------------------------------------------------------- /examples/search/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/meteor-react-highland/d42c1409849a322137dfced0967ef222ea799d90/examples/search/README.md -------------------------------------------------------------------------------- /examples/search/main.coffee: -------------------------------------------------------------------------------- 1 | @Posts = new Mongo.Collection('posts') 2 | 3 | if Meteor.isServer 4 | @Posts._ensureIndex('title') 5 | 6 | Meteor.publish 'search', (query, limit) -> 7 | filter = new RegExp(query, 'ig') 8 | Posts.find({title:filter}, {sort: {name: 1, date: -1}, limit: limit}) 9 | 10 | # seed the database with fake data 11 | Meteor.startup -> 12 | if Posts.find().count() is 0 13 | console.log "adding fake posts" 14 | for j in [0...10000] 15 | Posts.insert({title: Fake.sentence(3)}) 16 | 17 | 18 | _ = highland 19 | 20 | div = React.streamable(React.DOM.div) 21 | input = React.streamable(React.DOM.input) 22 | span = React.streamable(React.DOM.span) 23 | 24 | # blog post about this! 25 | 26 | search = _() 27 | _.route '/', (params, queryParams) -> 28 | (div {}, 29 | (search.fork().map (x) -> 30 | (input {value: x, placeholder:'Search', onChange: (e) -> search.write(e.target.value)}) 31 | ) 32 | (div {}, 33 | (search.fork() 34 | .debounce(500) 35 | .through _.filterRepeats 36 | .through _.subscribe (query) -> 37 | if query.length >= 1 38 | Meteor.subscribe('search', query, 20) 39 | .through _.multi _.map, (query, ready) -> 40 | if query.length < 1 41 | false 42 | else if not ready 43 | (div {}, "loading...") 44 | else 45 | _.autorunStream -> 46 | filter = new RegExp(query, 'ig') 47 | posts = Posts.find({title:filter}, {sort: {name: 1, date: -1}, limit: 20}).fetch() 48 | (div {}, 49 | do -> 50 | p = posts.map (post) -> 51 | (div {key:post._id}, post.title) 52 | if p.length is 0 53 | return "No results for '#{query}'" 54 | else 55 | return p 56 | ) 57 | .through _.switchOnNext 58 | ) 59 | ) 60 | ) 61 | .each (x) -> 62 | React.render(x, document.body) 63 | search.write('') 64 | 65 | -------------------------------------------------------------------------------- /examples/search/packages/ccorcos:highland: -------------------------------------------------------------------------------- 1 | ../../../../ccorcos:highland -------------------------------------------------------------------------------- /examples/search/packages/ccorcos:react: -------------------------------------------------------------------------------- 1 | ../../../../ccorcos:react -------------------------------------------------------------------------------- /examples/search/packages/ccorcos:react-highland: -------------------------------------------------------------------------------- 1 | ../../../../ccorcos:react-highland -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: "ccorcos:react-highland", 3 | version: "0.0.1", 4 | summary: "React + Highland + Meteor", 5 | git: "https://github.com/ccorcos/meteor-react-highland", 6 | }); 7 | 8 | 9 | Package.onUse(function(api) { 10 | api.use([ 11 | "coffeescript@1.0.5", 12 | "ccorcos:react@0.0.1", 13 | "ccorcos:highland@0.0.1", 14 | "meteorhacks:flow-router@1.0.2", 15 | ]); 16 | api.imply([ 17 | "ccorcos:react", 18 | "ccorcos:highland", 19 | ]); 20 | 21 | api.addFiles([ 22 | "src/react-highland.coffee", 23 | "src/highland-extensions.js", 24 | "src/meteor-highland.coffee", 25 | ]); 26 | }); -------------------------------------------------------------------------------- /src/highland-extensions.js: -------------------------------------------------------------------------------- 1 | var _ = highland; 2 | 3 | // takeUntil 4 | // _ = highland 5 | // a = _() 6 | // b = _() 7 | // a.through(_.takeUntil(b)).each(_.log) 8 | // a.write(1) 9 | // a.write(2) 10 | // b.write(1) 11 | // a.write(3) 12 | 13 | var takeUntil = _.curry(function(stream, source) { 14 | var first = true, 15 | done = false; 16 | return source.consume( function(err, x, push, next) { 17 | if (first) { 18 | stream.pull(function() { 19 | if (!done) { 20 | push(null, _.nil); 21 | done = true; 22 | } 23 | }) 24 | first = false; 25 | } 26 | if (err) { 27 | push(err) 28 | next() 29 | } 30 | else if (x === _.nil) { 31 | done = true 32 | push(null, x) 33 | } 34 | 35 | else if (!done) { 36 | push(null, x) 37 | next() 38 | } 39 | }); 40 | }); 41 | 42 | _.takeUntil = takeUntil 43 | 44 | // Stream.prototype.takeUntil = function(stream) { 45 | // return takeUntil(stream, this) 46 | // } 47 | 48 | // switchOnNext 49 | // _ = highland 50 | // a = _() 51 | // b = null 52 | // a.map(function(x){ 53 | // b = _() 54 | // b.write(x*2) 55 | // return b 56 | // }).through(switchOnNext).each(_.log) 57 | 58 | // a.write(1) 59 | // // => 2 60 | // a.write(2) 61 | // // => 4 62 | // b.write(1) 63 | // // => 1 64 | // b.write(6) 65 | // // => 6 66 | // a.write(4) 67 | // // => 8 68 | 69 | var coerceStream = function(x) { 70 | if (_.isStream(x)) { 71 | return x; 72 | } else { 73 | return _([x]); 74 | } 75 | }; 76 | 77 | var switchOnNext = function(source) { 78 | return _(function(outerPush) { 79 | var stop = null; 80 | return source 81 | .map(coerceStream) 82 | .map(function(stream) { 83 | if (stop) { 84 | stop.end(); 85 | stop = null; 86 | } 87 | stop = _(); 88 | return stream.through(takeUntil(stop)); 89 | }) 90 | .errors(function(err, push) { 91 | if (stop) { 92 | stop.end(); 93 | stop = null; 94 | } 95 | push(err); 96 | }) 97 | .consume(function(err, x, push, next) { 98 | outerPush(err, x); 99 | if (x !== _.nil) { 100 | next(); 101 | } 102 | }) 103 | .resume(); 104 | }).sequence(); 105 | }; 106 | 107 | _.switchOnNext = switchOnNext 108 | // Stream.prototype.switchOnNext = function() { 109 | // return switchOnNext(this) 110 | // } 111 | 112 | 113 | // _ = highland 114 | // a = _() 115 | // a.map(function(x){ 116 | // return x*2 117 | // }).through(switchOnNext).each(_.log) 118 | 119 | 120 | var onDone = _.curry(function(f, source) { 121 | return source.consume(function(err, x, push, next) { 122 | if (err) { 123 | push(err); 124 | next(); 125 | } 126 | else if (x === _.nil) { 127 | f(); 128 | push(null, x); 129 | } 130 | else { 131 | push(null, x); 132 | next(); 133 | } 134 | }); 135 | }); 136 | 137 | _.onDone = onDone 138 | 139 | // Stream.prototype.onDone = function(f) { 140 | // return onDone(f, this) 141 | // } 142 | 143 | var stopOn = function(stopStream, source) { 144 | return stopStream.through(onDone(function() { 145 | source.end(); 146 | })); 147 | }; 148 | 149 | _.stopOn = stopOn 150 | 151 | // Stream.prototype.stopOn = function(stopStream) { 152 | // return stopOn(stopStream, this) 153 | // } 154 | 155 | // _ = highland 156 | // a = _() 157 | // b = null 158 | // a.through(join(function(x){ 159 | // b = _() 160 | // b.write(x*2) 161 | // return b 162 | // })).through(multi(_.each, function(x,y){ 163 | // console.log(x,y) 164 | // })) 165 | 166 | // a.write(1) 167 | // // => 1 2 168 | // a.write(2) 169 | // // => 2 4 170 | // b.write(5) 171 | // // => 2 5 172 | // a.write(5) 173 | // // => 5 10 174 | 175 | 176 | // _ = highland 177 | // a = _() 178 | // b = null 179 | // c = null 180 | // a.through(join(function(x){ 181 | // b = _() 182 | // b.write(x*2) 183 | // return b 184 | // })).through(join(function(x,y){ 185 | // c = _() 186 | // c.write(x+y) 187 | // return c 188 | // })).through(multi(_.each, function(x,y,z){ 189 | // console.log(x,y,z) 190 | // })) 191 | // a.write(1) 192 | // // 1 2 3 193 | // a.write(2) 194 | // // 2 4 6 195 | // b.write(3) 196 | // // 2 3 5 197 | // b.write(5) 198 | // // 2 5 7 199 | // c.write(10) 200 | // // 2 5 10 201 | // a.write(4) 202 | // // 4 8 12 203 | 204 | var corceArray = function(x) { 205 | if (x instanceof Array) { 206 | return x; 207 | } else { 208 | return [x]; 209 | } 210 | }; 211 | 212 | var join = _.curry(function(f, source) { 213 | return source.map(function(x) { 214 | x = corceArray(x); 215 | return f.apply(null, x).map(function(y) { 216 | y = corceArray(y); 217 | return x.concat(y); 218 | }); 219 | }).through(switchOnNext); 220 | }); 221 | 222 | _.join = join 223 | 224 | // Stream.prototype.stopOn = function(f) { 225 | // join(f, this) 226 | // } 227 | 228 | var multi = function(transform, f) { 229 | return function(source) { 230 | return source.through(transform(function(arr) { 231 | return f.apply(null, arr); 232 | })); 233 | }; 234 | }; 235 | 236 | _.multi = multi 237 | 238 | 239 | // _ = highland 240 | // a = _() 241 | // a.through(filterRepeats).each(_.log) 242 | // a.write(1) 243 | // a.write(1) 244 | // a.write(1) 245 | // a.write(2) 246 | // a.write(1) 247 | // a.write(2) 248 | // a.write(2) 249 | 250 | var filterRepeats = function(source) { 251 | var last = null; 252 | return source.consume(function(err, x, push, next) { 253 | if (err) { 254 | push(err); 255 | next(); 256 | } 257 | else if (x === _.nil) { 258 | push(null, x); 259 | } 260 | else { 261 | if (last === null || x !== last) { 262 | last = x; 263 | push(null, x); 264 | } 265 | next(); 266 | } 267 | }); 268 | }; 269 | 270 | _.filterRepeats = filterRepeats 271 | 272 | // Stream.prototype.filterRepeats = function() { 273 | // return filterRepeats(this) 274 | // } -------------------------------------------------------------------------------- /src/meteor-highland.coffee: -------------------------------------------------------------------------------- 1 | _ = highland 2 | 3 | _.autorunStream = (f) -> 4 | stream = _() 5 | 6 | c = Tracker.autorun -> 7 | results = f() 8 | stream.write(results) 9 | 10 | stream.through _.onDone -> 11 | c.stop() 12 | 13 | subscribe = _.curry (f, source) -> 14 | sub = null 15 | source.through _.join (args...) -> 16 | # start the next sub before unsubscribing 17 | # so we dont waste subscriptions 18 | nextSub = f.apply(null, args) 19 | sub?.stop?() 20 | sub = nextSub 21 | _.autorunStream -> sub?.ready?() 22 | .through _.onDone -> 23 | sub?.stop() 24 | 25 | _.subscribe = subscribe 26 | 27 | # create a route stream for each a route 28 | _.route = (string, callback) -> 29 | s = _() 30 | FlowRouter.route(string, {action: (args...) -> 31 | s.write(callback.apply(FlowRouter, args)) 32 | }) 33 | return s -------------------------------------------------------------------------------- /src/react-highland.coffee: -------------------------------------------------------------------------------- 1 | _ = highland 2 | 3 | Stream = React.createFactory(React.createClass({ 4 | displayName: "Stream" 5 | componentWillMount: -> 6 | @props.stream.each (x) => 7 | if _.isStream(x) 8 | @component = (Stream {stream:x}) 9 | else 10 | @component = x 11 | @forceUpdate() 12 | componentWillUnmount: -> 13 | @props.stream.destroy() 14 | render: -> 15 | @component or false 16 | })) 17 | 18 | React.streamable = (factory) -> 19 | (props, children...) -> 20 | args = [props] 21 | c = children.map (x) -> 22 | if _.isStream(x) 23 | return (Stream {stream:x}) 24 | else 25 | return x 26 | return factory.apply(null, args.concat(c)) --------------------------------------------------------------------------------