From 583d079afae882d8a159347dc8657033b42a47a9 Mon Sep 17 00:00:00 2001 From: Kostia Palchyk Date: Sun, 18 Apr 2021 17:09:11 +0300 Subject: [PATCH 1/2] added reactive component constructor --- package.json | 2 +- src/components/RefSubject.ts | 23 +++++++ src/components/components.ts | 104 ++++++++++++++++++++++++++++++++ src/components/index.ts | 4 ++ src/components/useMount$.ts | 26 ++++++++ src/components/useUnmount$.ts | 26 ++++++++ src/{ => elements}/elements.ts | 2 +- src/{ => elements}/fragment.ts | 2 +- src/elements/index.ts | 3 + src/{ => elements}/intrinsic.ts | 0 src/index.ts | 3 +- tests/elements.test.tsx | 2 +- tests/fragment.test.tsx | 2 +- 13 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 src/components/RefSubject.ts create mode 100644 src/components/components.ts create mode 100644 src/components/index.ts create mode 100644 src/components/useMount$.ts create mode 100644 src/components/useUnmount$.ts rename src/{ => elements}/elements.ts (98%) rename src/{ => elements}/fragment.ts (97%) create mode 100755 src/elements/index.ts rename src/{ => elements}/intrinsic.ts (100%) mode change 100755 => 100644 src/index.ts diff --git a/package.json b/package.json index 3adc8d9..414e4cc 100755 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test": "jest", "test:watch": "jest --watch", "test:debug": "node --inspect node_modules/.bin/jest --watch --runInBand", - "np": "npm run clean && npm run build && np && npm run clean" + "np": "npm run clean && npm run build && np --any-branch && npm run clean" }, "repository": { "type": "git", diff --git a/src/components/RefSubject.ts b/src/components/RefSubject.ts new file mode 100644 index 0000000..6cbc687 --- /dev/null +++ b/src/components/RefSubject.ts @@ -0,0 +1,23 @@ +import { ReplaySubject } from "rxjs"; + + +export class RefSubject extends ReplaySubject { + private _value: T | undefined = void 0; + public get value(): T | undefined { + return this._value; + }; + + constructor() { + // always 1 last value replay + super(1); + + // NOTE: compiled library seem to be overriding Subject .next in + // constructor, which corrupts traditional class method override + const _next = this.next; + this.next = function next(value: T) { + this._value = value; + _next.call(this, value); + } + } + +} diff --git a/src/components/components.ts b/src/components/components.ts new file mode 100644 index 0000000..7f11e72 --- /dev/null +++ b/src/components/components.ts @@ -0,0 +1,104 @@ +import { memo, useEffect, useRef, useState } from "react"; +import { BehaviorSubject, isObservable, Observable, of } from "rxjs"; +import { createElement$ } from "../elements"; +import { EMPTY_DEPENDENCIES } from '../shared'; +import { RefSubject } from "./RefSubject"; + +export type BasicNodeType = JSX.Element | string | number; + +// Main hook getter +export interface TUseHook$ { + (fn: (p) => F): RefSubject +} + +let _globalUseHook: TUseHook$ | undefined = undefined; +export const useHook$: TUseHook$ = (fn) => { + if (_globalUseHook == undefined) { + throw new Error('Using hooks outside component constructor'); + }; + + return _globalUseHook(fn); +} + + +// NOTE: it's memo-ised and createElement$-ed, and we might need to optimise that +export function createComponent$

(fn: (props: Observable

) => BasicNodeType | Observable) { + return memo(createElement$((props: P) => { + const props$ = useRef>(); + const output$ = useRef>(); + const [output, setOutput] = useState(null); + + // Saving hooks and their streams for future calls + const hookFns = useRef([]); + const hookStreams = useRef[]>([]); + + // Unconditional props$ stream update + + // TODO: React.memo doesn't exactly guarantee that the component won't be + // called twice with the same props, it's rather a performance optimisation. + // So we should add a guard for that or a simple distinctUntilChanged if it + // works + if (!props$.current) { + props$.current = new BehaviorSubject

(props); + } else { + props$.current.next(props); + } + + // Call the component fn once + if (!output$.current) { + // Record all hooks used: they will be called in each render phase and + // their values pushed to respective Subjects + const useHook = fn => { + const hook$ = new RefSubject(); + hookFns.current.push(fn); + hookStreams.current.push(hook$); + + // TODO: this exposes Subject API to the user, which should be avoided + // in the future and an Observable interface with current value + // field exposed + return hook$; + } + + // Calling component fn, preserving prev and next useHook instance + const prevUseHook = _globalUseHook; + _globalUseHook = useHook; + const result = fn(props$.current); + _globalUseHook = prevUseHook; + + // Observable-ing the output + output$.current = isObservable(result) + ? result + : of(result); + } + + // If any hooks were used -- call all of them & push their results + // (we need to call all hooks on each render, in the same order) + if (hookFns.current.length != 0) { + const hookValues = hookFns.current.map(hook => hook()); + + useEffect(() => { + // NOTE: Deferring hook effects, so that .next don't initiate a chain of + // updates in current component's render phase + + // TODO: this might emit same hook results multiple times, might need some + // change distinction + + hookValues.forEach((v, i) => { + hookStreams.current[i].next(v); + }); + }, hookValues); + } + + // Binding output with + useEffect(() => { + const subscription = output$.current!.subscribe(setOutput); + + return () => { + props$.current!.complete(); // complete current stream + subscription.unsubscribe(); // unsubscribe from results + } + }, EMPTY_DEPENDENCIES); + + return output; + })); +} \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..845ae20 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './components'; +export * from './RefSubject'; +export * from './useMount$'; +export * from './useUnmount$'; \ No newline at end of file diff --git a/src/components/useMount$.ts b/src/components/useMount$.ts new file mode 100644 index 0000000..60245c3 --- /dev/null +++ b/src/components/useMount$.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; +import { ReplaySubject } from "rxjs"; +import { switchMap } from "rxjs/operators"; +import { EMPTY_DEPENDENCIES } from '../shared'; +import { useHook$ } from "./components"; + + +export function useMount$() { + return useHook$(() => { + const mount = useRef>(); + + if (!mount.current) { + mount.current = new ReplaySubject(1); + } + + useEffect(() => { + mount.current!.next(void 0); + return () => { + mount.current!.complete(); + }; + }, EMPTY_DEPENDENCIES); + + return mount.current; + }) + .pipe(switchMap(x => x)); +} diff --git a/src/components/useUnmount$.ts b/src/components/useUnmount$.ts new file mode 100644 index 0000000..19003a8 --- /dev/null +++ b/src/components/useUnmount$.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from "react"; +import { ReplaySubject } from "rxjs"; +import { switchMap } from "rxjs/operators"; +import { EMPTY_DEPENDENCIES } from '../shared'; +import { useHook$ } from "./components"; + + +export function useUnmount$() { + return useHook$(() => { + const unmount = useRef>(); + + if (!unmount.current) { + unmount.current = new ReplaySubject(1); + } + + useEffect(() => { + return () => { + unmount.current!.next(void 0); + unmount.current!.complete(); + }; + }, EMPTY_DEPENDENCIES); + + return unmount.current; + }) + .pipe(switchMap(x => x)); +} diff --git a/src/elements.ts b/src/elements/elements.ts similarity index 98% rename from src/elements.ts rename to src/elements/elements.ts index 905062e..71b66de 100644 --- a/src/elements.ts +++ b/src/elements/elements.ts @@ -2,7 +2,7 @@ import { ComponentClass, createElement, FunctionComponent, useEffect, useState } import { isObservable, Observable } from 'rxjs'; import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; import { $ } from './fragment'; -import { useDestroyObservable, useEmptyObject } from './shared'; +import { useDestroyObservable, useEmptyObject } from '../shared'; // TODO: handle ref diff --git a/src/fragment.ts b/src/elements/fragment.ts similarity index 97% rename from src/fragment.ts rename to src/elements/fragment.ts index 711be80..91b189f 100644 --- a/src/fragment.ts +++ b/src/elements/fragment.ts @@ -1,7 +1,7 @@ import { createElement, Fragment, useEffect, useState } from "react"; import { isObservable } from "rxjs"; import { distinctUntilChanged, takeUntil } from "rxjs/operators"; -import { useDestroyObservable } from "./shared"; +import { useDestroyObservable } from "../shared"; // TODO: add better TS support diff --git a/src/elements/index.ts b/src/elements/index.ts new file mode 100755 index 0000000..d3b57b3 --- /dev/null +++ b/src/elements/index.ts @@ -0,0 +1,3 @@ +export * from './elements'; +export * from './fragment'; +export * from './intrinsic'; diff --git a/src/intrinsic.ts b/src/elements/intrinsic.ts similarity index 100% rename from src/intrinsic.ts rename to src/elements/intrinsic.ts diff --git a/src/index.ts b/src/index.ts old mode 100755 new mode 100644 index d3b57b3..6a8f850 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ export * from './elements'; -export * from './fragment'; -export * from './intrinsic'; +export * from './components'; \ No newline at end of file diff --git a/tests/elements.test.tsx b/tests/elements.test.tsx index 8d68392..871944a 100644 --- a/tests/elements.test.tsx +++ b/tests/elements.test.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { render, unmountComponentAtNode } from "react-dom"; import { act } from "react-dom/test-utils"; import { Observable, of, Subject } from 'rxjs'; -import { $input, createElement$ } from '../src/index'; +import { $input, createElement$ } from '../src/elements/index'; // TODO: cover errors on Observables diff --git a/tests/fragment.test.tsx b/tests/fragment.test.tsx index 47896fb..2e9cde0 100644 --- a/tests/fragment.test.tsx +++ b/tests/fragment.test.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { render, unmountComponentAtNode } from "react-dom"; import { act } from "react-dom/test-utils"; import { Observable, of, Subject } from 'rxjs'; -import { $ } from '../src/index'; +import { $ } from '../src/elements/index'; import { ErrorBoundary } from './ErrorBoundary'; From 737fe2bbf82947f3db352b682da05b0c178998d9 Mon Sep 17 00:00:00 2001 From: Kostia Palchyk Date: Sun, 18 Apr 2021 17:17:44 +0300 Subject: [PATCH 2/2] 0.1.0-0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0377047..7d9d6ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { "name": "react-rxjs-elements", - "version": "0.0.6", + "version": "0.1.0-0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "0.0.6", + "version": "0.1.0-0", "license": "MIT", "devDependencies": { "@types/jest": "26.0.22", diff --git a/package.json b/package.json index 414e4cc..6f87a97 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-rxjs-elements", - "version": "0.0.6", + "version": "0.1.0-0", "description": "React fragment for RxJS content", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js",