├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── configs ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.es2015.json ├── tsconfig.esm.json ├── tsconfig.types.json ├── webpack-rxjs-externals.js ├── webpack.base.js ├── webpack.build.js ├── webpack.build.min.js └── webpack.dev.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── elements.ts ├── fragment.ts ├── index.ts ├── intrinsic.ts └── shared.ts ├── tests ├── ErrorBoundary.tsx ├── elements.test.tsx └── fragment.test.tsx ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Output 64 | dist/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.0.3] - 2020-08-07 4 | ### Added 5 | - createElement$ constructor for dynamic elements 6 | - a bunch of prefab elements 7 | 8 | ## [0.0.2] 9 | ### Added 10 | - error handling 11 | 12 | ## [0.0.1] 13 | ### Added 14 | - $ dynamic fragment -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 react-rxjs-elements 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 |
4 | <$> 5 |
6 | react elements for RxJS content 7 |
8 |
9 | NPM 10 | Bundlephobia 11 | MIT license 12 |
13 |
14 |
15 |

16 |
17 | 18 | 21 | 22 | Simply add an Observable as one of `<$>`'s children: 23 | 24 | ```tsx 25 | <$>{ stream$ } 26 | ``` 27 | 28 | or use a dynamic element, like $img 29 | 30 | ```tsx 31 | <$img src={ stream$ } /> 32 | ``` 33 | 34 | `react-rxjs-elements` will subscribe to the `stream$` and will display it's updates in place. 35 | And it will clean up all subscriptions for you on component unmount. 36 | 37 | Try it [**online**](https://stackblitz.com/edit/react-rxjs-elements?file=index.tsx) 38 | 39 | ## 📦 Install 40 | 41 | ``` 42 | npm i react-rxjs-elements 43 | ``` 44 | 45 | ## 📖 Examples 46 | 47 | A simple timer — [online sandbox](https://stackblitz.com/edit/react-rxjs-elements-timer?file=index.tsx) 48 | 49 | ```tsx 50 | import React from 'react'; 51 | import { timer } from 'rxjs'; 52 | import { $ } from 'react-rxjs-elements'; 53 | 54 | function App() { 55 | return <$>{ timer(0, 1000) } sec 56 | } 57 | ``` 58 | 59 | --- 60 | 61 | Dynamic image sources — [online sandbox](https://stackblitz.com/edit/react-rxjs-elements-img?file=index.tsx) 62 | 63 | ```tsx 64 | import React from 'react'; 65 | import { timer } from 'rxjs'; 66 | import { map } from 'rxjs/operators'; 67 | import { $img } from 'react-rxjs-elements'; 68 | 69 | function App() { 70 | const src$ = timer(0, 3000).pipe( 71 | map(x => (x % 2) ? 'a.jpg' : 'b.jpg') 72 | ); 73 | 74 | return <$img src={ src$ } /> 75 | } 76 | ``` 77 | 78 | --- 79 | 80 | A data fetch (with RxJS [ajax](https://rxjs.dev/api/ajax/ajax)) — [online sandbox](https://stackblitz.com/edit/react-rxjs-elements-fetch?file=index.tsx) 81 | 82 | ```tsx 83 | import React, { useMemo } from "react"; 84 | import { map, switchMap } from "rxjs/operators"; 85 | import { ajax } from "rxjs/ajax"; 86 | import { $ } from "react-rxjs-elements"; 87 | 88 | 89 | function App() { 90 | const data$ = useMemo(() => 91 | ajax.getJSON(URL) 92 | , []); 93 | 94 | return <$>{data$}; 95 | } 96 | ``` 97 | 98 | --- 99 | 100 | A counter — [online sandbox](https://stackblitz.com/edit/react-rxjs-elements-counter?file=index.tsx) 101 | 102 | ```tsx 103 | import React from 'react'; 104 | import { $div } from 'react-rxjs-elements'; 105 | import { Subject } from 'rxjs'; 106 | import { startWith, scan } from 'rxjs/operators'; 107 | 108 | function App() { 109 | const subject$ = useMemo(() => new Subject(), []); 110 | 111 | const output$ = useMemo(() => 112 | subject$.pipe( 113 | startWith(0), // start with a 0 114 | scan((acc, curr) => acc + curr) // then add +1 or -1 115 | ) 116 | , []); 117 | 118 | return <$div> 119 | 122 | 123 | { output$ /* results would be displayed in place */ } 124 | 125 | 128 | 129 | } 130 | ``` 131 | 132 | 133 | ## 🙂 Enjoy 134 | -------------------------------------------------------------------------------- /configs/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "outDir": "../dist/", 5 | "target": "es5", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "importHelpers": false, 9 | "noImplicitAny": false, 10 | "noUnusedLocals": false, 11 | "lib": [ 12 | "es5", 13 | ], 14 | "types": [ "node" ] 15 | } 16 | } -------------------------------------------------------------------------------- /configs/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/cjs", 5 | "module": "commonjs" 6 | }, 7 | "include": [ 8 | "../src/index.ts" 9 | ] 10 | } -------------------------------------------------------------------------------- /configs/tsconfig.es2015.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/es2015", 5 | "target": "es2015" 6 | }, 7 | "include": [ 8 | "../src/index.ts" 9 | ] 10 | } -------------------------------------------------------------------------------- /configs/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/esm" 5 | }, 6 | "include": [ 7 | "../src/index.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /configs/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/types", 5 | "emitDeclarationOnly": true, 6 | "declaration": true, 7 | "declarationMap": true 8 | }, 9 | "include": [ 10 | "../src/index.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /configs/webpack-rxjs-externals.js: -------------------------------------------------------------------------------- 1 | module.exports = function rxjsExternalsFactory() { 2 | return function rxjsExternals({ context, request }, callback) { 3 | if (request.match(/^rxjs(\/|$)/)) { 4 | var parts = request.split('/'); 5 | if (parts.length > 2) { 6 | console.warn('webpack-rxjs-externals no longer supports v5-style deep imports like rxjs/operator/map etc. It only supports rxjs v6 pipeable imports via rxjs/operators or from the root.'); 7 | } 8 | 9 | return callback(null, { 10 | root: parts, 11 | commonjs: request, 12 | commonjs2: request, 13 | amd: request 14 | }); 15 | } 16 | 17 | callback(); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /configs/webpack.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpackRxjsExternals = require('./webpack-rxjs-externals'); 3 | 4 | 5 | module.exports = { 6 | entry: './src/index.ts', 7 | output: { 8 | library: 'react-rxjs-elements', 9 | libraryTarget: 'umd', 10 | publicPath: '/dist/', 11 | umdNamedDefine: true 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /.ts$/, 17 | exclude: /node_modules/, 18 | use: [ 19 | { 20 | loader: 'ts-loader', 21 | options: { 22 | configFile: 'configs/tsconfig.esm.json' 23 | } 24 | 25 | } 26 | ] 27 | }, 28 | ] 29 | }, 30 | resolve: { 31 | extensions: ['.ts'] 32 | }, 33 | externals: [ 34 | { 35 | react: 'react' 36 | }, 37 | webpackRxjsExternals() 38 | ] 39 | }; 40 | -------------------------------------------------------------------------------- /configs/webpack.build.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | 5 | module.exports = merge(baseConfig, { 6 | mode: 'development', 7 | output: { 8 | filename: 'react-rxjs-elements.js' 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /configs/webpack.build.min.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | 5 | module.exports = merge(baseConfig, { 6 | mode: 'production', 7 | output: { 8 | filename: 'react-rxjs-elements.min.js' 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /configs/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | module.exports = merge( 6 | baseConfig, 7 | { 8 | mode: 'development', 9 | watch: true, 10 | plugins: [ 11 | new CleanWebpackPlugin() 12 | ] 13 | } 14 | ); 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-rxjs-elements", 3 | "version": "0.0.7", 4 | "description": "React fragment for RxJS content", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.js", 7 | "es2015": "./dist/es2015/index.js", 8 | "types": "./dist/types/index.d.ts", 9 | "unpkg": "./dist/react-rxjs-elements.min.js", 10 | "sideEffects": false, 11 | "scripts": { 12 | "start": "webpack --config configs/webpack.dev.js", 13 | "clean": "rimraf temp dist", 14 | "build": "npm run build:esm && npm run build:es2015 && npm run build:cjs && npm run build:types && npm run build:umd && npm run build:umd:min", 15 | "build:esm": "tsc -p configs/tsconfig.esm.json", 16 | "build:es2015": "tsc -p configs/tsconfig.es2015.json", 17 | "build:cjs": "tsc -p configs/tsconfig.cjs.json", 18 | "build:types": "tsc -p configs/tsconfig.types.json", 19 | "build:umd": "webpack --config configs/webpack.build.js -o dist", 20 | "build:umd:min": "webpack --config configs/webpack.build.min.js -o dist", 21 | "test": "jest", 22 | "test:watch": "jest --watch", 23 | "test:debug": "node --inspect node_modules/.bin/jest --watch --runInBand", 24 | "np": "npm run clean && npm run build && np && npm run clean" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/kosich/react-rxjs-elements.git" 29 | }, 30 | "keywords": [ 31 | "react", 32 | "rxjs", 33 | "javascript", 34 | "typescript" 35 | ], 36 | "author": "Kostiantyn Palchyk", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/kosich/react-rxjs-elements/issues" 40 | }, 41 | "homepage": "https://github.com/kosich/react-rxjs-elements#readme", 42 | "devDependencies": { 43 | "@types/jest": "26.0.22", 44 | "@types/react": "17.0.3", 45 | "clean-webpack-plugin": "3.0.0", 46 | "jest": "26.6.3", 47 | "np": "7.4.0", 48 | "react": "17.0.2", 49 | "react-dom": "17.0.2", 50 | "rimraf": "3.0.2", 51 | "rxjs": "6.6.7", 52 | "ts-jest": "26.5.5", 53 | "ts-loader": "8.1.0", 54 | "typescript": "4.2.4", 55 | "webpack": "5.33.2", 56 | "webpack-cli": "4.6.0", 57 | "webpack-merge": "5.7.3", 58 | "webpack-rxjs-externals": "2.0.0" 59 | }, 60 | "peerDependencies": { 61 | "react": "16.x||17.x||18.x", 62 | "rxjs": "6.x||7.x" 63 | }, 64 | "files": [ 65 | "dist" 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /src/elements.ts: -------------------------------------------------------------------------------- 1 | import { ComponentClass, createElement, FunctionComponent, useEffect, useState } from 'react'; 2 | import { isObservable, Observable } from 'rxjs'; 3 | import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; 4 | import { $ } from './fragment'; 5 | import { useDestroyObservable, useEmptyObject } from './shared'; 6 | 7 | // TODO: handle ref 8 | 9 | /** 10 | * creates a dynamic component that will subscribe to passed Observable props 11 | * 12 | * Example: 13 | * 14 | * ```jsx 15 | * import React from 'react'; 16 | * import { createElement$ } from 'react-rxjs-elements'; 17 | * import { timer } from 'rxjs'; 18 | * 19 | * function App(){ 20 | * const $div = createElement$('div'); 21 | * const timer$ = timer(0, 1000); 22 | * 23 | * return <$div title={ timer$ } >Hello world 24 | * } 25 | * ``` 26 | * 27 | * @param element element tag ('div', 'input', '...') or component function/class 28 | * @returns FunctionComponent that observes it's Observable params and children 29 | */ 30 | export function createElement$(element: T): FunctionComponent<{ [k in keyof JSX.IntrinsicElements[T]]: JSX.IntrinsicElements[T][k] | Observable }> 31 | export function createElement$(element: FunctionComponent

| ComponentClass): FunctionComponent<{ [k in keyof P]: P[k] | Observable }> 32 | export function createElement$(element) { 33 | return function element$(props) { 34 | // state for renderable props 35 | const [streamProps, setStreamProps] = useState(Object.create(null)); 36 | 37 | // placeholder for Observable.error case -- will use it to rethrow error 38 | const [error, setError] = useState<{ error: any }>(null); 39 | 40 | const destroy$ = useDestroyObservable(); 41 | 42 | // store prev props to compare 43 | const _prevStreamProps = useEmptyObject(); 44 | 45 | // keep subscriptions to unsubscribe on dynamic prop update 46 | const _subs = useEmptyObject(); 47 | 48 | useEffect(() => { 49 | let isDirty = false; 50 | 51 | // check for obsolete props 52 | const delProps = Object.create(null); 53 | Object.keys(_prevStreamProps).forEach(key => { 54 | if (key in props) { 55 | return; 56 | } 57 | 58 | isDirty = true; 59 | delete _prevStreamProps[key]; 60 | // if previous property was Observable 61 | // kill subscription 62 | cleanSubscription(_subs, key); 63 | // remove from static props 64 | delProps[key] = void 0; 65 | }); 66 | 67 | // update/track new props 68 | const nextProps = Object.create(null); 69 | const streamKeys = []; 70 | Object.keys(props).forEach(key => { 71 | // children are covered via <$> 72 | if (key == 'children') { 73 | return; 74 | } 75 | 76 | const value = props[key]; 77 | const prevValue = _prevStreamProps[key]; 78 | const equal = Object.is(value, prevValue); 79 | 80 | if (equal) { 81 | return; 82 | } 83 | 84 | // if property changes and previous was Observable 85 | // we need to kill subscription 86 | cleanSubscription(_subs, key); 87 | 88 | // observable input params are added to observation 89 | // all static props are directly updated 90 | if (isObservable(value)) { 91 | isDirty = true; 92 | _prevStreamProps[key] = value; 93 | nextProps[key] = void 0; // reset prev prop when new one is Observable 94 | streamKeys.push(key); 95 | } else { 96 | // forget outdated prev props 97 | delete _prevStreamProps[key]; 98 | } 99 | }); 100 | 101 | // subscribe to new streams 102 | // some values might be received in sync way: like `of(…)` or `startWith(…)` 103 | // to optimize this we update all syncronously received values in one 104 | // commit to the state 105 | // {{{ 106 | let isSync = true; 107 | streamKeys.forEach(key => { 108 | _subs[key] = props[key] 109 | .pipe( 110 | distinctUntilChanged(), 111 | takeUntil(destroy$) 112 | ) 113 | .subscribe({ 114 | // on value updates -- update props 115 | next(data) { 116 | // set sync values 117 | if (isSync) { 118 | isDirty = true; 119 | nextProps[key] = data; 120 | } else { 121 | // async set values 122 | setStreamProps(p => Object.assign({}, p, { [key]: data })); 123 | } 124 | }, 125 | // on error -- rethrow error 126 | error (error) { 127 | setError({ error }); 128 | } 129 | // on complete we just keep using accumulated value 130 | }) 131 | }); 132 | isSync = false; 133 | // }}} 134 | 135 | // remove obsolete props 136 | // & update static props 137 | if (isDirty) { 138 | setStreamProps(p => Object.assign({}, p, delProps, nextProps)); 139 | } 140 | }, [props]); 141 | 142 | // if error -- throw 143 | if (error) { 144 | throw error.error; 145 | } 146 | 147 | // using statically available props in render phase 148 | // so that `<$a alt="hello" href={ stream$ } >…` 149 | // renders `` immediately 150 | const derivedProps = Object.keys(props).reduce((p, key) => { 151 | if (isObservable(props[key])) { 152 | // ensure controlled elements stay controlled 153 | // if value is present and is not nullish 154 | // we make the input controlled 155 | if (key == 'value' 156 | && (element == 'input' 157 | && props.type != 'file' 158 | && streamProps.type != 'file' 159 | || element == 'select' 160 | || element == 'textarea' 161 | ) 162 | ) { 163 | p[key] = streamProps.value ?? ''; 164 | } else { 165 | p[key] = streamProps[key]; 166 | } 167 | } else { 168 | p[key] = props[key]; 169 | } 170 | 171 | return p; 172 | }, Object.create(null)); 173 | 174 | return createElement( 175 | element, 176 | derivedProps, 177 | // if children exist 178 | // they might be observable 179 | // so we pass em to <$> fragment 180 | // NOTE: children might not exist for elements like 181 | props.children 182 | ? createElement($, null, props.children) 183 | : null 184 | ); 185 | }; 186 | } 187 | 188 | // helpers 189 | function cleanSubscription(store, key) { 190 | if (store[key]) { 191 | store[key].unsubscribe(); 192 | delete store[key]; 193 | } 194 | } 195 | 196 | -------------------------------------------------------------------------------- /src/fragment.ts: -------------------------------------------------------------------------------- 1 | import { createElement, Fragment, useEffect, useState } from "react"; 2 | import { isObservable } from "rxjs"; 3 | import { distinctUntilChanged, takeUntil } from "rxjs/operators"; 4 | import { useDestroyObservable } from "./shared"; 5 | 6 | // TODO: add better TS support 7 | 8 | /** 9 | * <$> fragment will subscribe to it's Observable children and display 10 | * it's emissions along with regular children 11 | * 12 | * e.g. 13 | * 14 | * ```jsx 15 | * function App(){ 16 | * return <$>{ timer(0, 1000) } // 0, 1, 2, 3, ... 17 | * } 18 | * ``` 19 | */ 20 | export function $(props) { 21 | const children = props?.children; 22 | 23 | // CHORTCUT: 24 | // if fragment has many children -- we render it with a <> that has 25 | // <$> children in place of Observables 26 | if (Array.isArray(children)){ 27 | return createElement(Fragment, null, ...children.map(c => isObservable(c) ? createElement($, null, c) : c)); 28 | } 29 | 30 | // Single child: 31 | 32 | // state for Observable children 33 | const [streamChild, setStreamChild] = useState(null); 34 | 35 | // store error indicator 36 | const [error, setError] = useState(null); 37 | 38 | const destroy$ = useDestroyObservable(); 39 | 40 | // react to child updates 41 | useEffect(() => { 42 | if (!isObservable(children)) { 43 | return; 44 | } 45 | 46 | // child is a single observable 47 | // if the stream emits async - synchronously reset child to null 48 | // else - use value from the stream to update the child 49 | let syncChildValue = null; 50 | let isSync = true; 51 | const sub = children.pipe(distinctUntilChanged(), takeUntil(destroy$)).subscribe({ 52 | next(value) { 53 | // synchronous values would be set in one run 54 | if (isSync) { 55 | syncChildValue = value; 56 | } else { 57 | setStreamChild(value); 58 | } 59 | }, // update the view 60 | error(error) { 61 | // wrap error in an object to be safe in case the error is nullish 62 | setError({ error }); 63 | }, 64 | // on complete we just keep displaying accumulated value 65 | }); 66 | isSync = false; 67 | 68 | // make the sync update 69 | setStreamChild(syncChildValue); 70 | 71 | // clear subscription if Observable child changes 72 | return () => sub.unsubscribe(); 73 | }, [children]); 74 | 75 | // raise an error if Observable failed 76 | if (error) { 77 | throw error.error; 78 | } 79 | 80 | return isObservable(children) 81 | ? streamChild // read child updates from state 82 | : children; // child is a regular child, like you and me 83 | } 84 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './elements'; 2 | export * from './fragment'; 3 | export * from './intrinsic'; 4 | -------------------------------------------------------------------------------- /src/intrinsic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * preset intrinsic elements 3 | * 4 | * Q: should we prefab all possible elements? 5 | * Q: should that be statically generated on build or created on demand? 6 | */ 7 | 8 | import { createElement$ } from "./elements"; 9 | 10 | export const $a = createElement$("a"); 11 | export const $img = createElement$("img"); 12 | 13 | export const $h1 = createElement$("h1"); 14 | export const $h2 = createElement$("h2"); 15 | export const $h3 = createElement$("h3"); 16 | export const $h4 = createElement$("h4"); 17 | export const $h5 = createElement$("h5"); 18 | export const $h6 = createElement$("h6"); 19 | 20 | export const $p = createElement$("p"); 21 | export const $span = createElement$("span"); 22 | export const $div = createElement$("div"); 23 | 24 | export const $form = createElement$("form"); 25 | export const $input = createElement$("input"); 26 | export const $textarea = createElement$("textarea"); 27 | export const $select = createElement$("select"); 28 | export const $button = createElement$("button"); 29 | -------------------------------------------------------------------------------- /src/shared.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { Subject } from 'rxjs'; 3 | 4 | 5 | /** 6 | * reusable empty dependencies array for hooks 7 | */ 8 | export const EMPTY_DEPENDENCIES = []; 9 | 10 | /** 11 | * empty Object wiht no dependencies 12 | */ 13 | export function useEmptyObject() { 14 | const ref = useRef(Object.create(null)); 15 | return ref.current; 16 | } 17 | 18 | /** 19 | * destroy$ stream helper 20 | * it will emit an empty value on unmount 21 | */ 22 | export function useDestroyObservable() { 23 | const [destroy$] = useState(() => new Subject()); 24 | 25 | useEffect(() => () => { 26 | destroy$.next(void 0); 27 | destroy$.complete(); 28 | }, EMPTY_DEPENDENCIES); 29 | 30 | return destroy$.asObservable(); 31 | } 32 | -------------------------------------------------------------------------------- /tests/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class ErrorBoundary extends Component { 4 | constructor(props) { 5 | super(props); 6 | this.state = { hasError: false, error: void 0 }; 7 | } 8 | 9 | static getDerivedStateFromError(error) { 10 | // Update state so the next render will show the fallback UI. 11 | return { hasError: true, error }; 12 | } 13 | 14 | componentDidCatch(error, errorInfo) { 15 | // You can also log the error to an error reporting service 16 | console.log(error, errorInfo); 17 | } 18 | 19 | render() { 20 | if (this.state.hasError) { 21 | // You can render any custom fallback UI 22 | return <>ERROR:{this.state.error?.toString()}; 23 | } 24 | 25 | return this.props.children; 26 | } 27 | } 28 | 29 | export { ErrorBoundary }; 30 | -------------------------------------------------------------------------------- /tests/elements.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { render, unmountComponentAtNode } from "react-dom"; 3 | import { act } from "react-dom/test-utils"; 4 | import { Observable, of, Subject } from 'rxjs'; 5 | import { $input, createElement$ } from '../src/index'; 6 | 7 | // TODO: cover errors on Observables 8 | 9 | const $div = createElement$('div'); 10 | 11 | describe('Elements', () => { 12 | let rootElement; 13 | 14 | beforeEach(() => { 15 | rootElement = document.createElement("div"); 16 | document.body.appendChild(rootElement); 17 | }); 18 | 19 | afterEach(() => { 20 | unmountComponentAtNode(rootElement); 21 | rootElement.remove(); 22 | rootElement = null; 23 | }); 24 | 25 | test('dynamic children', () => { 26 | const content$ = new Subject(); 27 | const App = () => <$div>Hello, {content$}; 28 | act(() => { render(, rootElement); }); 29 | expect(rootElement.innerHTML).toBe('

Hello,
'); 30 | act(() => { content$.next('world'); }); 31 | expect(rootElement.innerHTML).toBe('
Hello, world
'); 32 | }); 33 | 34 | describe('Params', () => { 35 | 36 | test('static input', () => { 37 | const App = () => <$div title="Hello">world; 38 | render(, rootElement); 39 | expect(rootElement.innerHTML).toBe('
world
'); 40 | }); 41 | 42 | test('static output', () => { 43 | const content$ = new Subject(); 44 | 45 | const App = () => <$div onClick={() => content$.next('hello')}>{content$}; 46 | act(() => { render(, rootElement); }); 47 | expect(rootElement.innerHTML).toBe('
'); 48 | 49 | const div = rootElement.querySelector('div'); 50 | act(() => { 51 | div.dispatchEvent(new MouseEvent('click', { bubbles: true })); 52 | }); 53 | 54 | expect(rootElement.innerHTML).toBe('
hello
'); 55 | }); 56 | 57 | // Currently we subscribe in useEffect phase 58 | // therefore attribute wont be immediately available 59 | // this is a subject for further improvements 60 | test('immediate stream attr', () => { 61 | const App = () => <$div title={of('Hello')}>world; 62 | render(, rootElement); 63 | expect(rootElement.innerHTML).toBe('
world
'); 64 | }); 65 | 66 | test('delayed stream attr', () => { 67 | const content$ = new Subject(); 68 | const App = () => <$div title={content$}>world; 69 | act(() => { render(, rootElement); }); 70 | expect(rootElement.innerHTML).toBe('
world
'); 71 | act(() => { content$.next('Hello'); }); 72 | expect(rootElement.innerHTML).toBe('
world
'); 73 | }); 74 | 75 | }); 76 | 77 | describe('Updates', () => { 78 | it('should unsubscribe from previous observable', () => { 79 | let setState, setState2; 80 | let i = 0; 81 | const unsub = jest.fn(); 82 | const createSource = () => 83 | new Observable((observer) => { 84 | observer.next(i); 85 | i++; 86 | return () => unsub(); 87 | }); 88 | 89 | const updateSource = () => { 90 | setState(createSource()) 91 | } 92 | 93 | const App = () => { 94 | const s1 = useState(null); 95 | const s2 = useState(0); 96 | [, setState] = s1; 97 | [, setState2] = s2; 98 | const [source$] = s1; 99 | return <$div title={source$}>; 100 | }; 101 | 102 | act(() => { render(, rootElement); }); 103 | expect(rootElement.innerHTML).toBe('
'); 104 | // change static null to stream 105 | act(updateSource); 106 | expect(rootElement.innerHTML).toBe('
'); 107 | expect(unsub.mock.calls.length).toBe(0); 108 | // update unrelated state 109 | act(() => setState2((x) => x + 1)); 110 | expect(rootElement.innerHTML).toBe('
'); 111 | expect(unsub.mock.calls.length).toBe(0); 112 | // change stream to static 113 | act(() => setState('Hello')); 114 | expect(rootElement.innerHTML).toBe('
'); 115 | expect(unsub.mock.calls.length).toBe(1); 116 | // change static to stream 117 | act(updateSource); 118 | expect(rootElement.innerHTML).toBe('
'); 119 | expect(unsub.mock.calls.length).toBe(1); 120 | // change stream to stream 121 | act(updateSource); 122 | expect(rootElement.innerHTML).toBe('
'); 123 | expect(unsub.mock.calls.length).toBe(2); 124 | // change stream to static 125 | act(() => setState('World')); 126 | expect(rootElement.innerHTML).toBe('
'); 127 | expect(unsub.mock.calls.length).toBe(3); 128 | }) 129 | 130 | it('should not make useless updates if props are equal', () => { 131 | let setState; 132 | const updateState = () => setState(x => x + 1); 133 | const spy = jest.fn(() => null); 134 | const Child = createElement$(spy); 135 | const App = () => { 136 | [, setState] = useState(null); 137 | return ; 138 | }; 139 | 140 | act(() => { render(, rootElement); }); 141 | expect(spy.mock.calls.length).toBe(1); 142 | act(updateState); 143 | expect(spy.mock.calls.length).toBe(2); 144 | act(updateState); 145 | expect(spy.mock.calls.length).toBe(3); 146 | }) 147 | 148 | it('should remove value from obsolete stream', () => { 149 | let setState; 150 | let subject$ = new Subject(); 151 | const App = () => { 152 | const [state$, _setState] = useState(null); 153 | setState = _setState; 154 | return <$div title={state$}>; 155 | }; 156 | 157 | act(() => { render(, rootElement); }); 158 | expect(rootElement.innerHTML).toBe('
'); 159 | act(() => setState(subject$)); 160 | expect(rootElement.innerHTML).toBe('
'); 161 | act(() => { subject$.next(0); }); 162 | expect(rootElement.innerHTML).toBe('
'); 163 | act(() => { 164 | subject$ = new Subject(); 165 | setState(subject$); 166 | }); 167 | expect(rootElement.innerHTML).toBe('
'); 168 | act(() => { subject$.next(1); }); 169 | expect(rootElement.innerHTML).toBe('
'); 170 | }) 171 | }) 172 | 173 | describe('Input value', () => { 174 | it('should not set input value if absent', () => { 175 | const App = () => <$input />; 176 | act(() => { render(, rootElement); }); 177 | expect(rootElement.innerHTML).toBe(''); 178 | }) 179 | 180 | // NOTE: this doesn't test controlled -> uncontrolled switch 181 | it('should set input value immediately', () => { 182 | const content$ = new Subject(); 183 | const App = () => <$input readOnly value={content$} />; 184 | act(() => { 185 | render(, rootElement); 186 | // NOTE: checking presense of value synchronously, inside act 187 | expect(rootElement.innerHTML).toBe(''); 188 | }); 189 | act(() => { content$.next('world'); }); 190 | expect(rootElement.innerHTML).toBe(''); 191 | expect(rootElement.children[0].value).toBe('world'); 192 | }) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /tests/fragment.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { render, unmountComponentAtNode } from "react-dom"; 3 | import { act } from "react-dom/test-utils"; 4 | import { Observable, of, Subject } from 'rxjs'; 5 | import { $ } from '../src/index'; 6 | import { ErrorBoundary } from './ErrorBoundary'; 7 | 8 | 9 | describe('Fragment', () => { 10 | let rootElement; 11 | 12 | beforeEach(() => { 13 | rootElement = document.createElement("div"); 14 | document.body.appendChild(rootElement); 15 | }); 16 | 17 | afterEach(() => { 18 | unmountComponentAtNode(rootElement); 19 | rootElement.remove(); 20 | rootElement = null; 21 | }); 22 | 23 | describe('Static', () => { 24 | it('should render only static child instantly', () =>{ 25 | const App = () => <$>Hello world; 26 | render(, rootElement); 27 | expect(rootElement.innerHTML).toBe('Hello world'); 28 | }); 29 | 30 | it('should render mixed static child instantly', () =>{ 31 | const App = () => <$>Hello world { of(1) }; 32 | render(, rootElement); 33 | expect(rootElement.innerHTML).toBe('Hello world '); 34 | }); 35 | }) 36 | 37 | describe('Single root', () => { 38 | let content$: Subject; 39 | 40 | beforeEach(() => { 41 | content$ = new Subject(); 42 | const App = () => <$>{content$}; 43 | 44 | act(() => { render(, rootElement); }); 45 | }); 46 | 47 | afterEach(() => { 48 | content$.complete(); 49 | content$ = null; 50 | }); 51 | 52 | 53 | test('empty', () => { 54 | expect(rootElement.innerHTML).toBe(''); 55 | }); 56 | 57 | test('consequent renders', () => { 58 | act(() => { content$.next('ONE'); }); 59 | 60 | expect(rootElement.innerHTML).toBe('ONE'); 61 | 62 | act(() => { content$.next('TWO'); }); 63 | 64 | expect(rootElement.innerHTML).toBe('TWO'); 65 | 66 | act(() => { content$.complete(); }); 67 | 68 | expect(rootElement.innerHTML).toBe('TWO'); 69 | }); 70 | }); 71 | 72 | describe('Multiple roots', () => { 73 | it('should render two streams', () =>{ 74 | const a$ = new Subject(); 75 | const b$ = new Subject(); 76 | const App = () => <$>{a$} and {b$}; 77 | act(() => { render(, rootElement); }) 78 | act(() => { a$.next('a'); }) 79 | expect(rootElement.innerHTML).toBe('a and '); 80 | act(() => { b$.next('b'); }); 81 | expect(rootElement.innerHTML).toBe('a and b'); 82 | }); 83 | }); 84 | 85 | describe('Error', () => { 86 | let content$: Subject; 87 | 88 | beforeEach(() => { 89 | content$ = new Subject(); 90 | const App = () => <$>{content$}; 91 | act(() => { render(, rootElement); }); 92 | }); 93 | 94 | afterEach(() => { 95 | content$.complete(); 96 | content$ = null; 97 | }); 98 | 99 | test('errors', () => { 100 | // NOTE: this error propagates to the console 101 | // it doesn't affect passing test, but creates visual mess 102 | // TODO: suppress the error 103 | act(() => { content$.error('ONE'); }); 104 | expect(rootElement.innerHTML).toBe('ERROR:ONE'); 105 | }); 106 | }); 107 | 108 | describe('Stream to Static updates', () => { 109 | it('should unsubscribe from previous observable', () => { 110 | let setState; 111 | let i = 0; 112 | const unsub = jest.fn(); 113 | const createSource = () => 114 | new Observable(observer => { 115 | observer.next(i); 116 | i++; 117 | return () => unsub(); 118 | }); 119 | 120 | const updateSource = () => { 121 | setState(createSource()) 122 | } 123 | 124 | const App = () => { 125 | const [source$, _setState] = useState(null); 126 | setState = _setState; 127 | return <$>{source$}; 128 | }; 129 | 130 | act(() => { render(, rootElement); }); 131 | 132 | expect(rootElement.innerHTML).toBe(''); 133 | act(updateSource); 134 | expect(rootElement.innerHTML).toBe('0'); 135 | expect(unsub.mock.calls.length).toBe(0); 136 | act(updateSource); 137 | expect(rootElement.innerHTML).toBe('1'); 138 | expect(unsub.mock.calls.length).toBe(1); 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "outDir": "./dist/", 5 | "noImplicitAny": false, 6 | "noUnusedLocals": false, 7 | "moduleResolution": "node", 8 | "module": "commonjs", 9 | "target": "es2015", 10 | "sourceMap": true, 11 | "jsx": "react", 12 | "types" : ["jest"], 13 | "esModuleInterop": true 14 | } 15 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./configs/webpack.base'); 2 | 3 | module.exports = config; 4 | --------------------------------------------------------------------------------