├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── .gitkeep ├── integration │ └── examples │ │ └── ts.spec.ts ├── plugins │ └── index.js ├── support │ └── .gitkeep └── tsconfig.json ├── dangerfile.ts ├── examples ├── contactsList.tsx ├── example.tsx ├── index.html ├── index.tsx └── webpack.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.spec.tsx └── index.tsx ├── test.config.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.rpt2_cache 3 | /node_modules 4 | /coverage 5 | /build 6 | /examples/*.js 7 | /examples/*.map 8 | 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | cypress/videos 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.rpt2_cache 3 | /src 4 | /test 5 | /examples 6 | /coverage 7 | 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | examples/**/*.js 4 | coverage/ 5 | *.json 6 | *.md 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "search.exclude": { 4 | "**/node_modules": true 5 | }, 6 | "typescript.tsdk": "node_modules/typescript/lib/", 7 | "editor.tabSize": 2, 8 | "editor.formatOnType": true, 9 | "editor.formatOnSave": true, 10 | "tslint.autoFixOnSave": true, 11 | "tsimporter.preferRelative": false, 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2018, Michał Miszczyszyn 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-with-observable 2 | [![buddy pipeline](https://app.buddy.works/mmiszy/react-with-observable/pipelines/pipeline/225575/badge.svg?token=dfc09b1458ffdb6655820015738f0e3bc1a515874804b85799fe6af214f3473c "buddy pipeline")](https://app.buddy.works/mmiszy/react-with-observable/pipelines/pipeline/225575) 3 | [![codecov](https://codecov.io/gh/mmiszy/react-with-observable/branch/master/graph/badge.svg)](https://codecov.io/gh/mmiszy/react-with-observable) 4 | [![cypress dashboard](https://img.shields.io/badge/cypress-dashboard-brightgreen.svg)](https://dashboard.cypress.io/#/projects/dmnv1v/runs) 5 | [![npm](https://img.shields.io/npm/v/react-with-observable.svg)](https://www.npmjs.com/package/react-with-observable) 6 | [![npm bundle size (minified + gzip)](https://img.shields.io/bundlephobia/minzip/react-with-observable.svg)](https://www.npmjs.com/package/react-with-observable) 7 | 8 | 9 | 10 | `react-with-observable`: use Observables declaratively in ⚛️ React! 11 | 12 | * ✅ Supports any Observable implementation compatible with ECMAScript Observable (e.g. **RxJS**)! 13 | * ✅ Inspired by the `AsyncPipe` from Angular! 14 | * ✅ Very extensible by composing Observable operators! 15 | * ✅ TypeScript definitions included! 16 | 17 | It handles subscribing and unsubscribing automatically and, hence, you don't have to worry about memory leaks or updating state when new values come! 18 | 19 | Inspired by the `AsyncPipe` from Angular. Uses React's [`create-subscription`](https://github.com/facebook/react/tree/master/packages/create-subscription) under the hood. 20 | 21 | ## Install 22 | ```javascript 23 | npm install --save react-with-observable create-subscription 24 | ``` 25 | 26 | Get a polyfill for `Symbol.observable` if you need one (you most likely do). 27 | 28 | ```javascript 29 | npm install --save symbol-observable 30 | ``` 31 | 32 | Remember to `import 'symbol-observable'` **before** `rxjs` or `react-with-observable`! 33 | 34 | ## Usage 35 | The component supports any Observable library compatible with the [Observables for ECMAScript draft proposal](https://github.com/tc39/proposal-observable). 36 | 37 | ### Basics 38 | This package exports a single named component `Subscribe`. It expects you to provide an Observable as its only child: 39 | 40 | ```javascript 41 | const source$ = Observable.of('Hello, world!'); 42 | // … 43 | {source$} 44 | ``` 45 | 46 | This results in "Hello, world!" being displayed. 47 | 48 | ### Reactivity 49 | The component automatically updates whenever a new value is emitted by the Observable: 50 | 51 | ```javascript 52 | const source$ = Observable.interval(1000); 53 | // … 54 | {source$} 55 | ``` 56 | 57 | As a result, the next integer is displayed every second. 58 | 59 | 60 | ### Operators 61 | You can transform the Observable as you wish, as long as the final result is also an Observable: 62 | 63 | ```javascript 64 | const source$ = Observable.interval(1000); 65 | // … 66 | 67 | {source$.pipe( 68 | map(val => 10 * val), 69 | scan((acc, val) => acc + val, 0), 70 | map(val => ) 71 | )} 72 | 73 | ``` 74 | 75 | As the result, an `` element is rendered. Its value is changed every second to 0, 10, 30, 60, 100, and so on. 76 | 77 | ### Initial value 78 | Use your Observable library! `react-with-observable` doesn't implement any custom way to provide the default value and it doesn't need to. For example, with RxJS, you can use the `startWith` operator: 79 | 80 | ```javascript 81 | 82 | {source$.pipe( 83 | startWith(null) 84 | )} 85 | 86 | ``` 87 | 88 | ## Example 89 | You can find more interactive examples here: https://mmiszy.github.io/react-with-observable/ 90 | 91 | ```javascript 92 | import 'symbol-observable'; 93 | import * as React from 'react'; 94 | import { Link } from 'react-router-dom'; 95 | import { map, startWith } from 'rxjs/operators'; 96 | import { Subscribe } from 'react-with-observable'; 97 | 98 | // myContacts$ is an Observable of an array of contacts 99 | 100 | export class ContactsList extends React.Component { 101 | render() { 102 | return ( 103 |
104 |

My Contacts

105 | 106 | {myContacts$.pipe( 107 | startWith(null), 108 | map(this.renderList) 109 | )} 110 | 111 |
112 | ); 113 | } 114 | 115 | renderList = (contacts) => { 116 | if (!contacts) { 117 | return 'Loading…'; 118 | } 119 | 120 | if (!contacts.length) { 121 | return 'You have 0 contacts. Add some!'; 122 | } 123 | 124 | return ( 125 | 134 | ); 135 | }; 136 | } 137 | ``` 138 | 139 | ## Bugs? Feature requests? 140 | Feel free to create a new issue: [issues](https://github.com/mmiszy/react-with-observable/issues). Pull requests are also welcome! 141 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8082", 3 | "projectId": "dmnv1v" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/fixtures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb/react-with-observable/7d9af3836ff5e8a6f59d1c86d35ca8bdc8fba278/cypress/fixtures/.gitkeep -------------------------------------------------------------------------------- /cypress/integration/examples/ts.spec.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | describe('TypeScript', () => { 5 | it('works', () => { 6 | cy.visit('/'); 7 | cy.get('h1').should('contain', 'react-with-observable — examples'); 8 | }); 9 | 10 | it('should display hello, world', () => { 11 | cy.visit('/'); 12 | cy.get('#example-1').contains('Hello, world!'); 13 | }); 14 | 15 | it('should display new number every second', () => { 16 | cy.visit('/'); 17 | cy.get('#example-2').contains('1'); 18 | cy.wait(1000); 19 | cy.get('#example-2').contains('2'); 20 | cy.wait(1000); 21 | cy.get('#example-2').contains('3'); 22 | }); 23 | 24 | it('should have input with a new value every second', () => { 25 | cy.visit('/'); 26 | cy.get('#example-3 input').should('have.value', '0'); 27 | cy.wait(1000); 28 | cy.get('#example-3 input').should('have.value', '10'); 29 | cy.wait(1000); 30 | cy.get('#example-3 input').should('have.value', '30'); 31 | cy.wait(1000); 32 | cy.get('#example-3 input').should('have.value', '60'); 33 | }); 34 | 35 | it('should fetch and display contacts', () => { 36 | let totalContacts = 0; 37 | 38 | cy.server(); 39 | cy.route({ 40 | url: 'api/**', 41 | method: 'GET', 42 | }).as('getContacts'); 43 | 44 | function countContacts(xhr: Cypress.WaitXHR): number { 45 | const pattern = /\/\?results=(\d+)/; 46 | const res = xhr.url.match(pattern); 47 | 48 | const usersCount = Number(res && res[1]); 49 | return usersCount; 50 | } 51 | 52 | function assertContacts(xhr: Cypress.WaitXHR) { 53 | const usersCount = countContacts(xhr); 54 | totalContacts += usersCount; 55 | 56 | expect(usersCount) 57 | .to.be.at.least(3) 58 | .and.at.most(5); 59 | 60 | cy.get('.current-contacts ul li').should('have.length', usersCount); 61 | cy.get('.all-contacts ul li').should('have.length', totalContacts); 62 | } 63 | 64 | cy.visit('/'); 65 | 66 | cy.get('.current-contacts').contains('Loading…'); 67 | cy.get('.all-contacts').contains('Loading…'); 68 | 69 | cy.wait(1000); 70 | // setTimeout 71 | cy.get('.current-contacts').contains('No contacts.'); 72 | cy.get('.all-contacts').contains('No contacts.'); 73 | 74 | // trigger first fetch 75 | cy.get('#example-contacts button').click({ force: true }); 76 | cy.wait('@getContacts').then(xhr => { 77 | assertContacts(xhr); 78 | 79 | // trigger second fetch 80 | cy.get('#example-contacts button').click({ force: true }); 81 | cy.wait('@getContacts').then(xhr => { 82 | assertContacts(xhr); 83 | }); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | const webpack = require('@cypress/webpack-preprocessor'); 14 | 15 | module.exports = (on, config) => { 16 | // `on` is used to hook into various events Cypress emits 17 | // `config` is the resolved Cypress config 18 | 19 | const options = { 20 | // send in the options from your webpack.config.js, so it works the same 21 | // as your app's code 22 | webpackOptions: require('../../examples/webpack.config'), 23 | watchOptions: {}, 24 | }; 25 | 26 | on('file:preprocessor', webpack(options)); 27 | }; 28 | -------------------------------------------------------------------------------- /cypress/support/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb/react-with-observable/7d9af3836ff5e8a6f59d1c86d35ca8bdc8fba278/cypress/support/.gitkeep -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [ 4 | "../node_modules/cypress", 5 | "../node_modules/cypress/types/chai", 6 | "*/*.ts" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /dangerfile.ts: -------------------------------------------------------------------------------- 1 | import { danger, warn } from 'danger'; 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | 5 | // No PR is too small to include a description of why you made a change 6 | if (danger.github.pr.body.length < 10) { 7 | warn('Please include a description of your PR changes.'); 8 | } 9 | 10 | // Request changes to src also include changes to tests. 11 | const allFiles = danger.git.modified_files.concat(danger.git.created_files); 12 | const hasAppChanges = allFiles.some(p => p.includes('.tsx')); 13 | const hasTestChanges = allFiles.some(p => p.includes('.spec.tsx')); 14 | 15 | const modifiedSpecFiles = danger.git.modified_files.filter(function(filePath) { 16 | return filePath.match(/\.spec\.(js|jsx|ts|tsx)$/gi); 17 | }); 18 | 19 | const testFilesIncludeExclusion = modifiedSpecFiles.reduce( 20 | function(acc, value) { 21 | var content = fs.readFileSync(value).toString(); 22 | var invalid = content.includes('it.only') || content.includes('describe.only'); 23 | if (invalid) { 24 | acc.push(path.basename(value)); 25 | } 26 | return acc; 27 | }, 28 | [] as string[] 29 | ); 30 | 31 | if (testFilesIncludeExclusion.length > 0) { 32 | fail('An `only` was left in tests (' + testFilesIncludeExclusion.join(', ') + ')'); 33 | } 34 | 35 | if (hasAppChanges && !hasTestChanges) { 36 | warn('This PR does not include changes to tests, even though it affects app code.'); 37 | } 38 | -------------------------------------------------------------------------------- /examples/contactsList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { map, startWith, scan } from 'rxjs/operators'; 3 | import { BehaviorSubject } from 'rxjs'; 4 | import { ajax } from 'rxjs/ajax'; 5 | 6 | // @ts-ignore 7 | import { Subscribe } from 'react-with-observable'; 8 | 9 | type Contact = { 10 | id: string; 11 | fullName: string; 12 | description: string; 13 | }; 14 | const myContacts$ = new BehaviorSubject(null); 15 | 16 | // simulate empty contacts list with a delay 17 | setTimeout(() => { 18 | myContacts$.next([]); 19 | }, 2000); 20 | 21 | class ContactsList extends React.Component { 22 | allContacts$ = myContacts$.pipe( 23 | scan((acc, contacts) => { 24 | return [...(acc || []), ...(contacts || [])]; 25 | }) 26 | ); 27 | 28 | recentContacts$ = myContacts$.pipe(startWith(null as Contact[] | null)); 29 | 30 | render() { 31 | return ( 32 |
33 |

My Contacts

34 |

Recently fetched contacts

35 |
 36 |           
 37 |             {`
 38 | recentContacts$ = myContacts$.pipe(startWith(null as Contact[] | null));
 39 | {recentContacts$.pipe(map(this.renderList))}
 40 | `.trim()}
 41 |           
 42 |         
43 |
44 | {this.recentContacts$.pipe(map(this.renderList))} 45 |
46 | 47 |

All contacts

48 |
 49 |           
 50 |             {`
 51 | allContacts$ = myContacts$.pipe(
 52 |   scan((acc, contacts) => {
 53 |     return [...(acc || []), ...(contacts || [])];
 54 |   })
 55 | );
 56 | {allContacts$.pipe(map(this.renderList))}
 57 | `.trim()}
 58 |           
 59 |         
60 |
61 | {this.allContacts$.pipe(map(this.renderList))} 62 |
63 |
64 | ); 65 | } 66 | 67 | renderList = (contacts: Contact[] | null) => { 68 | if (!contacts) { 69 | return Loading…; 70 | } 71 | 72 | if (!contacts.length) { 73 | return No contacts.; 74 | } 75 | 76 | return ( 77 |
    78 | {contacts.map(contact => ( 79 |
  • 80 | {contact.fullName} — {contact.description} 81 |
  • 82 | ))} 83 |
84 | ); 85 | }; 86 | } 87 | 88 | export class ContactsExample extends React.Component { 89 | render() { 90 | return ( 91 |
92 | 95 | 96 |
97 | ); 98 | } 99 | 100 | addContacts() { 101 | const count = Math.floor(Math.random() * 3) + 3; 102 | myContacts$.next(null); 103 | ajax 104 | .get('https://randomuser.me/api/?results=' + count) 105 | .pipe( 106 | map(res => { 107 | const results: Array = res.response.results; 108 | return results.map(r => ({ 109 | id: r.login.uuid, 110 | fullName: `${r.name.title} ${r.name.first} ${r.name.last}`, 111 | description: r.email, 112 | })); 113 | }) 114 | ) 115 | .subscribe(val => myContacts$.next(val)); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /examples/example.tsx: -------------------------------------------------------------------------------- 1 | import 'symbol-observable'; 2 | import * as React from 'react'; 3 | import { of, interval } from 'rxjs'; 4 | 5 | // @ts-ignore 6 | import { Subscribe } from 'react-with-observable'; 7 | import { map, scan } from 'rxjs/operators'; 8 | 9 | const source1$ = of('Hello, world!'); 10 | const source2$ = interval(1000); 11 | const source3$ = interval(1000); 12 | 13 | export const Example1 = () => {source1$}; 14 | 15 | export const Example2 = () => {source2$}; 16 | 17 | export const Example3 = () => ( 18 | 19 | {source3$.pipe( 20 | map(val => 10 * val), 21 | scan((acc, val) => acc + val, 0), 22 | map(val => ) 23 | )} 24 | 25 | ); 26 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | react-with-observable examples 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 57 | 58 | 59 | 60 |
61 |

62 | react-with-observable — examples 63 |

64 |

Use Observables with React declaratively!

65 | 66 |
67 |

Before you start

68 |

69 | The following examples are created with RxJS. Make sure to include the 70 | Symbol.observable polyfill before you start: 71 |

72 | 73 |
import 'symbol-observable';
 74 | import { of, interval, BehaviorSubject } from 'rxjs';
 75 | import { map, startWith, scan } from 'rxjs/operators';
 76 | import { ajax } from 'rxjs/ajax';
 77 | import { Subscribe } from 'react-with-observable';
78 |

First example

79 |
const source1$ = of('Hello, world!');
 80 | <Subscribe>{source1$}</Subscribe>
81 |
82 |
83 | 84 |
85 |

Timer example

86 |
const source2$ = interval(1000);
 87 | <Subscribe>{source2$}</Subscribe>
88 |
89 |
90 | 91 |
92 |

Operators example

93 |
const source3$ = interval(1000);
 94 | <Subscribe>
 95 |   {source3$.pipe(
 96 |     map(val => 10 * val),
 97 |     scan((acc, val) => acc + val, 0),
 98 |     map(val => <input value={val} />)
 99 |   )}
100 | </Subscribe>
101 |
102 |
103 | 104 |
105 |

Contacts list example

106 |
107 |
108 |
109 | 110 | 111 | 112 | 113 | 120 | 121 | 159 | 160 | 161 | 162 | 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /examples/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Example1, Example2, Example3 } from './example'; 4 | import { ContactsExample } from './contactsList'; 5 | 6 | //@ts-ignore 7 | console.log('Running App version ' + VERSION); 8 | 9 | ReactDOM.render(, document.getElementById('example-1') as HTMLElement); 10 | ReactDOM.render(, document.getElementById('example-2') as HTMLElement); 11 | ReactDOM.render(, document.getElementById('example-3') as HTMLElement); 12 | ReactDOM.render(, document.getElementById('example-contacts') as HTMLElement); 13 | -------------------------------------------------------------------------------- /examples/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const GitRevisionPlugin = require('git-revision-webpack-plugin'); 4 | const gitRevisionPlugin = new GitRevisionPlugin(); 5 | 6 | module.exports = { 7 | mode: 'development', 8 | devtool: 'source-map', 9 | entry: ['./examples/index.tsx'], 10 | output: { 11 | path: path.resolve(__dirname), 12 | filename: '[name].js', 13 | }, 14 | devServer: { 15 | contentBase: path.resolve(__dirname), 16 | port: 8082 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | exclude: /(node_modules)/, 23 | use: [ 24 | { 25 | loader: 'ts-loader', 26 | options: { compilerOptions: { declaration: false, moduleResolution: 'node' } }, 27 | }, 28 | ], 29 | }, 30 | ], 31 | }, 32 | plugins: [ 33 | new webpack.DefinePlugin({ 34 | 'VERSION': JSON.stringify(gitRevisionPlugin.version()), 35 | }) 36 | ], 37 | resolve: { 38 | alias: { 39 | 'react-with-observable': path.join(__dirname, '..', 'src'), 40 | }, 41 | extensions: ['.tsx', '.jsx', '.ts', '.js'], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | 5 | roots: ['/src'], 6 | setupFilesAfterEnv: ['/test.config.ts'], 7 | collectCoverageFrom: ['src/**/*.{ts,tsx}', '!**/node_modules/**'], 8 | restoreMocks: true, 9 | testEnvironment: 'jsdom', 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mmiszy/react-with-observable", 3 | "version": "3.1.0", 4 | "description": "Use Observables with React declaratively!", 5 | "main": "build/index.common.js", 6 | "browser": "build/index.js", 7 | "unpkg": "build/index.js", 8 | "module": "build/index.esm.js", 9 | "types": "build/index.d.ts", 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:mmiszy/react-with-observable.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/mmiszy/react-with-observable/issues" 16 | }, 17 | "homepage": "https://github.com/mmiszy/react-with-observable#readme", 18 | "scripts": { 19 | "build": "rimraf build && rollup -c", 20 | "test": "npm-run-all -p test:coverage test:types", 21 | "test:ci": "npm-run-all test", 22 | "test:e2e": "npm-run-all build:examples -p --race serve:examples test:cypress", 23 | "test:e2e:ci": "npm-run-all build:examples -p --race serve:examples test:cypress:ci", 24 | "test:types": "tslint -c ./tslint.json --project ./tsconfig.json", 25 | "test:cypress": "cypress run", 26 | "test:cypress:ci": "cypress run --parallel --ci-build-id=$BUILD_ID", 27 | "test:coverage": "jest --detectLeaks --coverage", 28 | "build:examples": "webpack --config examples/webpack.config.js --mode production", 29 | "serve:examples": "http-server ./examples -a localhost -p 8082 -c-1", 30 | "start:examples": "webpack-dev-server --config examples/webpack.config.js", 31 | "deploy:examples": "npm run build:examples && gh-pages --dist examples", 32 | "release": "np --no-yarn", 33 | "prepare": "npm run build", 34 | "postdeploy": "npm run deploy:examples" 35 | }, 36 | "keywords": [], 37 | "author": "Michał Miszczyszyn (https://typeofweb.com/)", 38 | "license": "ISC", 39 | "devDependencies": { 40 | "@cypress/webpack-preprocessor": "4.1.1", 41 | "@types/chai": "4.2.5", 42 | "@types/create-subscription": "16.4.2", 43 | "@types/enzyme": "3.10.3", 44 | "@types/enzyme-adapter-react-16": "1.0.5", 45 | "@types/jest": "24.0.23", 46 | "@types/react-dom": "16.9.4", 47 | "codecov": "3.6.1", 48 | "create-subscription": "16.9.0", 49 | "cypress": "3.6.1", 50 | "danger": "9.2.8", 51 | "enzyme": "3.10.0", 52 | "enzyme-adapter-react-16": "1.15.1", 53 | "gh-pages": "2.1.1", 54 | "git-revision-webpack-plugin": "3.0.4", 55 | "http-server": "0.11.1", 56 | "husky": "3.1.0", 57 | "jest": "24.9.0", 58 | "npm-run-all": "4.1.5", 59 | "prettier": "1.19.1", 60 | "pretty-quick": "2.0.1", 61 | "react": "16.9.0", 62 | "react-dom": "16.12.0", 63 | "rimraf": "3.0.0", 64 | "rollup": "1.27.5", 65 | "rollup-plugin-commonjs": "10.1.0", 66 | "rollup-plugin-node-resolve": "5.2.0", 67 | "rollup-plugin-typescript2": "0.25.2", 68 | "rxjs": "6.5.3", 69 | "source-map-support": "0.5.16", 70 | "symbol-observable": "1.2.0", 71 | "ts-jest": "24.2.0", 72 | "ts-loader": "6.2.1", 73 | "ts-node": "8.5.2", 74 | "tslint": "5.20.1", 75 | "typescript": "3.7.2", 76 | "typestrict": "1.0.2", 77 | "weak": "1.0.1", 78 | "webpack": "4.41.2", 79 | "webpack-cli": "3.3.10", 80 | "webpack-dev-server": "3.9.0" 81 | }, 82 | "peerDependencies": { 83 | "react": "^16.12.0", 84 | "create-subscription": "^16.12.0" 85 | }, 86 | "husky": { 87 | "hooks": { 88 | "pre-commit": "pretty-quick --staged" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | 5 | import pkg from './package.json'; 6 | 7 | export default [ 8 | { 9 | input: 'src/index.tsx', 10 | output: [ 11 | { 12 | name: 'react-with-observable', 13 | file: pkg.browser, 14 | format: 'umd', 15 | globals: { 16 | react: 'React', 17 | 'create-subscription': 'createSubscription', 18 | }, 19 | }, 20 | { 21 | name: 'react-with-observable', 22 | file: pkg.module, 23 | format: 'es', 24 | }, 25 | { 26 | name: 'react-with-observable', 27 | file: pkg.main, 28 | format: 'cjs', 29 | }, 30 | ], 31 | plugins: [ 32 | resolve(), 33 | commonjs({ 34 | include: 'node_modules/**', 35 | }), 36 | typescript(), 37 | ], 38 | external: ['react', 'react-dom', 'create-subscription'], 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /src/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Subscribe } from './index'; 2 | import { of, interval, BehaviorSubject, Observable } from 'rxjs'; 3 | import { map, scan } from 'rxjs/operators'; 4 | import { mount, ReactWrapper } from 'enzyme'; 5 | import * as React from 'react'; 6 | 7 | describe('test', () => { 8 | describe('with data', () => { 9 | it('should render the value', () => { 10 | const source$ = of(1); 11 | const el = mount({source$}); 12 | expect(el.text()).toEqual('1'); 13 | 14 | el.unmount(); 15 | }); 16 | 17 | it('should render next values', async () => { 18 | jest.useFakeTimers(); 19 | 20 | const source$ = interval(100); 21 | 22 | const el = mount({source$}); 23 | for (let i = 0; i < 10; ++i) { 24 | jest.advanceTimersByTime(100); 25 | el.update(); 26 | expect(el.text()).toEqual(String(i)); 27 | } 28 | 29 | el.unmount(); 30 | }); 31 | 32 | it('works fine with operators', () => { 33 | jest.useFakeTimers(); 34 | 35 | const source$ = interval(100); 36 | 37 | const el = mount( 38 | 39 | {source$.pipe( 40 | map(val => 10 * val), 41 | scan((acc: number, val) => acc + val, 0) 42 | )} 43 | 44 | ); 45 | 46 | jest.advanceTimersByTime(100); 47 | el.update(); 48 | expect(el.text()).toEqual('0'); 49 | 50 | jest.advanceTimersByTime(100); 51 | el.update(); 52 | expect(el.text()).toEqual('10'); 53 | 54 | jest.advanceTimersByTime(100); 55 | el.update(); 56 | expect(el.text()).toEqual('30'); 57 | 58 | jest.advanceTimersByTime(100); 59 | el.update(); 60 | expect(el.text()).toEqual('60'); 61 | 62 | jest.advanceTimersByTime(100); 63 | el.update(); 64 | expect(el.text()).toEqual('100'); 65 | 66 | el.unmount(); 67 | }); 68 | 69 | it('allows rendering elements', () => { 70 | jest.useFakeTimers(); 71 | 72 | const source$ = interval(100); 73 | 74 | const el = mount( 75 | 76 | {source$.pipe( 77 | map(val => 10 * val), 78 | scan((acc, val) => acc + val, 0), 79 | map(val => ) 80 | )} 81 | 82 | ); 83 | 84 | let input: HTMLInputElement; 85 | 86 | // @todo why el.find('input') or el.is('input') are not working? 87 | jest.advanceTimersByTime(100); 88 | input = el.getDOMNode(); 89 | expect(input.tagName).toBe('INPUT'); 90 | expect(input.value).toBe('0'); 91 | 92 | jest.advanceTimersByTime(100); 93 | input = el.getDOMNode(); 94 | expect(input.tagName).toBe('INPUT'); 95 | expect(input.value).toBe('10'); 96 | 97 | jest.advanceTimersByTime(100); 98 | input = el.getDOMNode(); 99 | expect(input.tagName).toBe('INPUT'); 100 | expect(input.value).toBe('30'); 101 | 102 | jest.advanceTimersByTime(100); 103 | input = el.getDOMNode(); 104 | expect(input.tagName).toBe('INPUT'); 105 | expect(input.value).toBe('60'); 106 | 107 | jest.advanceTimersByTime(100); 108 | input = el.getDOMNode(); 109 | expect(input.tagName).toBe('INPUT'); 110 | expect(input.value).toBe('100'); 111 | 112 | el.unmount(); 113 | }); 114 | 115 | it('should work with BehaviourSubject', () => { 116 | const source$ = new BehaviorSubject(123); 117 | const el = mount({source$}); 118 | expect(el.text()).toEqual('123'); 119 | 120 | el.unmount(); 121 | }); 122 | }); 123 | 124 | describe('lack of data', () => { 125 | it('should return empty render for an observable of undefined', () => { 126 | const source$ = of(undefined); 127 | const el = mount({source$}); 128 | expect(el.childAt(0).isEmptyRender()).toBe(true); 129 | 130 | el.unmount(); 131 | }); 132 | 133 | it('should return empty render for an observable of null', () => { 134 | const source$ = of(null); 135 | const el = mount({source$}); 136 | expect(el.childAt(0).isEmptyRender()).toBe(true); 137 | 138 | el.unmount(); 139 | }); 140 | 141 | it('should return empty render for an observable without a value', () => { 142 | const source$ = new Observable(); 143 | 144 | const el = mount({source$}); 145 | expect(el.childAt(0).isEmptyRender()).toBe(true); 146 | 147 | el.unmount(); 148 | }); 149 | }); 150 | 151 | describe('errors', () => { 152 | it('should throw an error when no obserable is passed', () => { 153 | const source$ = {} as any; 154 | 155 | // stfu React 156 | jest.spyOn(console, 'error').mockImplementation(() => undefined); 157 | let el: ReactWrapper | undefined; 158 | const test = () => { 159 | el = mount({source$}); 160 | }; 161 | expect(test).toThrow(); 162 | 163 | el && el.unmount(); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createSubscription } from 'create-subscription'; 2 | import { Subscription as SubscriptionComponent } from 'create-subscription'; 3 | import * as React from 'react'; 4 | export { SubscriptionComponent }; 5 | 6 | export interface Observable { 7 | subscribe(onNext: (val: T) => any, onError?: Function, onComplete?: Function): Subscription; 8 | [Symbol.observable](): this; 9 | } 10 | 11 | export interface Subscription { 12 | unsubscribe(): void; 13 | } 14 | 15 | export interface SubscribeProps { 16 | children: SubscribeChildren; 17 | } 18 | 19 | // @todo rxjs has incorrect typings because it lacks `[Symbol.observable](): this` method 20 | // so I added `subscribe` here to make it compatible with rxjs 21 | type SubscribeChildren = 22 | | { subscribe: Observable['subscribe'] } 23 | | { [Symbol.observable](): Observable }; 24 | 25 | export class Subscribe extends React.PureComponent> { 26 | private SubscriptionComponent = this.getSubscriptionComponent(); 27 | 28 | render() { 29 | const observable = this.getObservableFromChildren(); 30 | 31 | return ( 32 | 33 | {val => { 34 | if (typeof val === 'undefined') { 35 | return null; 36 | } 37 | return val; 38 | }} 39 | 40 | ); 41 | } 42 | 43 | private getObservableFromChildren() { 44 | const child = this.props.children as Observable; 45 | 46 | const observable: Observable | undefined = 47 | typeof child[Symbol.observable] === 'function' ? child[Symbol.observable]() : undefined; 48 | 49 | if (!observable) { 50 | throw new Error( 51 | `: Expected children to be a single Observable instance with a [Symbol.observable] method. See more: https://github.com/tc39/proposal-observable` 52 | ); 53 | } 54 | 55 | return observable; 56 | } 57 | 58 | private getSubscriptionComponent() { 59 | const SubscriptionComponent = createSubscription, T | undefined>({ 60 | getCurrentValue(_observable) { 61 | return undefined; 62 | }, 63 | subscribe(observable, callback) { 64 | const subscription = observable.subscribe(callback); 65 | return () => subscription.unsubscribe(); 66 | }, 67 | }); 68 | 69 | return SubscriptionComponent; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test.config.ts: -------------------------------------------------------------------------------- 1 | import 'symbol-observable'; 2 | 3 | import { configure } from 'enzyme'; 4 | import * as Adapter from 'enzyme-adapter-react-16'; 5 | 6 | configure({ adapter: new Adapter() }); 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build/compiled", 4 | "target": "es5", 5 | "module": "esnext", 6 | "jsx": "react", 7 | "declaration": true, 8 | "lib": ["esnext", "dom"], 9 | "sourceMap": true, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | 16 | "allowSyntheticDefaultImports": true, 17 | "skipLibCheck": true 18 | }, 19 | "include": [ 20 | "src" 21 | ], 22 | "exclude": [ 23 | "src/**/*.spec.tsx" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "typestrict" 3 | } 4 | --------------------------------------------------------------------------------