├── .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 |
(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