├── .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 | [](https://app.buddy.works/mmiszy/react-with-observable/pipelines/pipeline/225575)
3 | [](https://codecov.io/gh/mmiszy/react-with-observable)
4 | [](https://dashboard.cypress.io/#/projects/dmnv1v/runs)
5 | [](https://www.npmjs.com/package/react-with-observable)
6 | [](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 |
126 | {contacts.map(contact => (
127 | -
128 |
129 | {contact.fullName} — {contact.description}
130 |
131 |
132 | ))}
133 |
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 |
--------------------------------------------------------------------------------