react-rx 2.0.4 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/dist/cjs/__tests__/strictmode.test.d.ts +1 -0
  4. package/dist/cjs/__tests__/strictmode.test.js +111 -0
  5. package/dist/cjs/__tests__/useAsObservable.test.d.ts +1 -0
  6. package/dist/cjs/__tests__/useAsObservable.test.js +77 -0
  7. package/dist/cjs/__tests__/useObservable.test.d.ts +1 -0
  8. package/dist/cjs/__tests__/useObservable.test.js +116 -0
  9. package/dist/cjs/displayName.js +1 -0
  10. package/dist/cjs/reactiveComponent.js +2 -0
  11. package/dist/cjs/useAsObservable.js +12 -3
  12. package/dist/cjs/useIsomorphicEffect.d.ts +2 -2
  13. package/dist/cjs/useObservable.js +22 -6
  14. package/dist/cjs/utils.js +5 -0
  15. package/dist/es2015/__tests__/strictmode.test.d.ts +1 -0
  16. package/dist/es2015/__tests__/strictmode.test.js +69 -0
  17. package/dist/es2015/__tests__/useAsObservable.test.d.ts +1 -0
  18. package/dist/es2015/__tests__/useAsObservable.test.js +72 -0
  19. package/dist/es2015/__tests__/useObservable.test.d.ts +1 -0
  20. package/dist/es2015/__tests__/useObservable.test.js +110 -0
  21. package/dist/es2015/displayName.js +1 -0
  22. package/dist/es2015/reactiveComponent.js +2 -0
  23. package/dist/es2015/useAsObservable.js +13 -4
  24. package/dist/es2015/useIsomorphicEffect.d.ts +2 -2
  25. package/dist/es2015/useObservable.js +23 -7
  26. package/dist/es2015/utils.js +5 -0
  27. package/dist/esm/__tests__/strictmode.test.d.ts +1 -0
  28. package/dist/esm/__tests__/strictmode.test.js +109 -0
  29. package/dist/esm/__tests__/useAsObservable.test.d.ts +1 -0
  30. package/dist/esm/__tests__/useAsObservable.test.js +72 -0
  31. package/dist/esm/__tests__/useObservable.test.d.ts +1 -0
  32. package/dist/esm/__tests__/useObservable.test.js +114 -0
  33. package/dist/esm/displayName.js +1 -0
  34. package/dist/esm/reactiveComponent.js +2 -0
  35. package/dist/esm/useAsObservable.js +13 -4
  36. package/dist/esm/useIsomorphicEffect.d.ts +2 -2
  37. package/dist/esm/useObservable.js +23 -7
  38. package/dist/esm/utils.js +5 -0
  39. package/package.json +106 -24
  40. package/src/__tests__/strictmode.test.ts +79 -0
  41. package/src/__tests__/useAsObservable.test.tsx +94 -0
  42. package/src/__tests__/useObservable.test.tsx +145 -0
  43. package/src/displayName.ts +1 -0
  44. package/src/reactiveComponent.ts +3 -0
  45. package/src/useAsObservable.ts +14 -4
  46. package/src/useObservable.ts +30 -10
  47. package/src/useWithObservable.ts +1 -0
  48. package/src/utils.ts +5 -0
  49. package/.eslintrc.json +0 -13
  50. package/.idea/codeStyles/Project.xml +0 -113
  51. package/.idea/codeStyles/codeStyleConfig.xml +0 -5
  52. package/.idea/compiler.xml +0 -6
  53. package/.idea/encodings.xml +0 -4
  54. package/.idea/inspectionProfiles/Project_Default.xml +0 -7
  55. package/.idea/jsonSchemas.xml +0 -25
  56. package/.idea/markdown-navigator-enh.xml +0 -29
  57. package/.idea/markdown-navigator.xml +0 -55
  58. package/.idea/markdown.xml +0 -9
  59. package/.idea/misc.xml +0 -0
  60. package/.idea/modules.xml +0 -8
  61. package/.idea/react-rx.iml +0 -17
  62. package/.idea/vcs.xml +0 -6
  63. package/.prettierrc +0 -8
  64. package/jest.config.ts +0 -14
  65. package/tsconfig.json +0 -12
@@ -9,6 +9,7 @@ function fromComponent(component) {
9
9
  wrappedComponent.displayName = wrapDisplayName(component, 'reactiveComponent');
10
10
  return wrappedComponent;
11
11
  }
12
+ // eslint-disable-next-line @typescript-eslint/ban-types
12
13
  function fromObservable(input$) {
13
14
  return function ReactiveComponent() {
14
15
  return createElement(Fragment, null, useObservable(input$));
@@ -19,6 +20,7 @@ export function reactiveComponent(observableOrComponent) {
19
20
  ? fromComponent(observableOrComponent)
20
21
  : fromObservable(observableOrComponent);
21
22
  }
23
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/ban-types
22
24
  export function forwardRef(component) {
23
25
  var wrappedComponent = reactForwardRef(function (props, ref) {
24
26
  return createElement(Fragment, null, useObservable(useRef(component(useAsObservable(props), ref)).current));
@@ -1,6 +1,5 @@
1
1
  import { BehaviorSubject } from 'rxjs';
2
- import { useCallback, useRef } from 'react';
3
- import { useIsomorphicEffect } from './useIsomorphicEffect';
2
+ import { useCallback, useEffect, useRef } from 'react';
4
3
  import { distinctUntilChanged } from 'rxjs/operators';
5
4
  export function useAsObservable(value, operator) {
6
5
  var setup = useCallback(function () {
@@ -13,18 +12,28 @@ export function useAsObservable(value, operator) {
13
12
  ref.current = setup();
14
13
  }
15
14
  var observable = ref.current[0];
16
- useIsomorphicEffect(function () {
15
+ useEffect(function () {
17
16
  if (!ref.current) {
18
17
  return;
19
18
  }
20
19
  var _a = ref.current, subject = _a[1];
21
20
  subject.next(value);
22
21
  }, [value, ref]);
23
- useIsomorphicEffect(function () {
22
+ var shouldRestoreSubscriptionRef = useRef(false);
23
+ useEffect(function () {
24
+ if (shouldRestoreSubscriptionRef.current) {
25
+ if (!ref.current) {
26
+ ref.current = setup();
27
+ }
28
+ shouldRestoreSubscriptionRef.current = false;
29
+ }
24
30
  return function () {
25
31
  if (!ref.current) {
26
32
  return;
27
33
  }
34
+ // React StrictMode will call effects as `setup + teardown + setup` thus we can't trust this callback as "react is about to unmount"
35
+ // Tracking this ref lets us set the subscription back up on the next `setup` call if needed, and if it really did unmounted then all is well
36
+ shouldRestoreSubscriptionRef.current = true;
28
37
  var _a = ref.current, subject = _a[1];
29
38
  subject.complete();
30
39
  ref.current = undefined;
@@ -1,2 +1,2 @@
1
- import { useLayoutEffect } from 'react';
2
- export declare const useIsomorphicEffect: typeof useLayoutEffect;
1
+ import { useEffect } from 'react';
2
+ export declare const useIsomorphicEffect: typeof useEffect;
@@ -1,7 +1,6 @@
1
- import { useMemo } from 'react';
1
+ import { useEffect, useMemo, useRef } from 'react';
2
2
  import { useSyncExternalStore } from 'use-sync-external-store/shim';
3
3
  import { shareReplay, tap } from 'rxjs/operators';
4
- import { useIsomorphicEffect } from './useIsomorphicEffect';
5
4
  function getValue(value) {
6
5
  return typeof value === 'function' ? value() : value;
7
6
  }
@@ -10,6 +9,7 @@ function getOrCreateStore(inputObservable, initialValue) {
10
9
  if (!cache.has(inputObservable)) {
11
10
  var entry_1 = { currentValue: initialValue };
12
11
  entry_1.observable = inputObservable.pipe(shareReplay({ refCount: true, bufferSize: 1 }), tap(function (value) { return (entry_1.currentValue = value); }));
12
+ // Eagerly subscribe to sync set `entry.currentValue` to what the observable returns
13
13
  entry_1.subscription = entry_1.observable.subscribe();
14
14
  cache.set(inputObservable, entry_1);
15
15
  }
@@ -17,22 +17,38 @@ function getOrCreateStore(inputObservable, initialValue) {
17
17
  }
18
18
  export function useObservable(observable, initialValue) {
19
19
  var _a = useMemo(function () {
20
- var record = getOrCreateStore(observable, getValue(initialValue));
20
+ var store = getOrCreateStore(observable, getValue(initialValue));
21
+ if (store.subscription.closed) {
22
+ store.subscription = store.observable.subscribe();
23
+ }
21
24
  return [
22
25
  function getSnapshot() {
23
- return record.currentValue;
26
+ // @TODO: perf opt opportunity: we could do `store.subscription.unsubscribe()` here to clear up some memory, as this subscription is only needed to provide a sync initialValue.
27
+ return store.currentValue;
24
28
  },
25
29
  function subscribe(callback) {
26
- var sub = record.observable.subscribe(function (next) { return callback(next); });
30
+ // @TODO: perf opt opportunity: we could do `store.subscription.unsubscribe()` here as we only need 1 subscription active to keep the observer alive
31
+ var sub = store.observable.subscribe(callback);
27
32
  return function () {
28
33
  sub.unsubscribe();
29
34
  };
30
35
  },
31
36
  ];
32
37
  }, [observable]), getSnapshot = _a[0], subscribe = _a[1];
33
- useIsomorphicEffect(function () {
38
+ var shouldRestoreSubscriptionRef = useRef(false);
39
+ useEffect(function () {
40
+ var store = getOrCreateStore(observable, getValue(initialValue));
41
+ if (shouldRestoreSubscriptionRef.current) {
42
+ if (store.subscription.closed) {
43
+ store.subscription = store.observable.subscribe();
44
+ }
45
+ shouldRestoreSubscriptionRef.current = false;
46
+ }
34
47
  return function () {
35
- getOrCreateStore(observable, getValue(initialValue)).subscription.unsubscribe();
48
+ // React StrictMode will call effects as `setup + teardown + setup` thus we can't trust this callback as "react is about to unmount"
49
+ // Tracking this ref lets us set the subscription back up on the next `setup` call if needed, and if it really did unmounted then all is well
50
+ shouldRestoreSubscriptionRef.current = !store.subscription.closed;
51
+ store.subscription.unsubscribe();
36
52
  };
37
53
  }, [observable]);
38
54
  return useSyncExternalStore(subscribe, getSnapshot);
package/dist/esm/utils.js CHANGED
@@ -4,10 +4,13 @@ import { startWith } from 'rxjs/operators';
4
4
  import { useContext, useRef, useState } from 'react';
5
5
  var createState = function (initialState) { return observableCallback(startWith(initialState)); };
6
6
  export function observeState(initial) {
7
+ // eslint-disable-next-line react-hooks/rules-of-hooks
7
8
  var _a = useState(initial), value = _a[0], update = _a[1];
9
+ // eslint-disable-next-line react-hooks/rules-of-hooks
8
10
  return [useAsObservable(value), update];
9
11
  }
10
12
  export function observeCallback(operator) {
13
+ // eslint-disable-next-line react-hooks/rules-of-hooks
11
14
  var ref = useRef();
12
15
  if (!ref.current) {
13
16
  ref.current = operator ? observableCallback(operator) : observableCallback();
@@ -15,9 +18,11 @@ export function observeCallback(operator) {
15
18
  return ref.current;
16
19
  }
17
20
  export function observeContext(context) {
21
+ // eslint-disable-next-line react-hooks/rules-of-hooks
18
22
  return useAsObservable(useContext(context));
19
23
  }
20
24
  export function observeElement() {
25
+ // eslint-disable-next-line react-hooks/rules-of-hooks
21
26
  var ref = useRef();
22
27
  if (!ref.current) {
23
28
  ref.current = createState(null);
package/package.json CHANGED
@@ -1,11 +1,61 @@
1
1
  {
2
2
  "name": "react-rx",
3
- "version": "2.0.4",
3
+ "version": "2.1.1",
4
4
  "description": "React + RxJS = <3",
5
+ "keywords": [
6
+ "action",
7
+ "behavior",
8
+ "concurrent",
9
+ "es6",
10
+ "events",
11
+ "external",
12
+ "hooks",
13
+ "observables",
14
+ "observe",
15
+ "pipe",
16
+ "react",
17
+ "react18",
18
+ "reactive",
19
+ "realtime",
20
+ "rx",
21
+ "rxjs",
22
+ "sanity",
23
+ "sanity-io",
24
+ "store",
25
+ "streams",
26
+ "subject",
27
+ "suspense",
28
+ "sync",
29
+ "typesafe",
30
+ "typescript",
31
+ "use-sync-external-store",
32
+ "use"
33
+ ],
34
+ "homepage": "https://react-rx.dev",
35
+ "bugs": {
36
+ "url": "https://github.com/sanity-io/react-rx/issues"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/sanity-io/react-rx.git"
41
+ },
42
+ "license": "MIT",
43
+ "author": "Sanity.io <hello@sanity.io>",
44
+ "contributors": [
45
+ "Bjørge Næss <bjoerge@gmail.com>",
46
+ "Cody Olsen <stipsan@gmail.com> (https://github.com/stipsan)"
47
+ ],
5
48
  "main": "dist/cjs/index.js",
6
- "types": "dist/cjs/index.d.ts",
7
49
  "module": "dist/esm/index.js",
8
- "es2015": "dist/es2015/index.js",
50
+ "types": "dist/cjs/index.d.ts",
51
+ "files": [
52
+ "dist",
53
+ "src"
54
+ ],
55
+ "workspaces": [
56
+ "website",
57
+ "."
58
+ ],
9
59
  "scripts": {
10
60
  "build": "npm run build:es2015 && npm run build:esm && npm run build:cjs",
11
61
  "build:es2015": "tsc --module es2015 --target es2015 --outDir dist/es2015",
@@ -14,25 +64,20 @@
14
64
  "clean": "rimraf dist",
15
65
  "dev": "cd website && npm run dev",
16
66
  "prepublishOnly": "npm run clean && npm run build",
17
- "postpublish": "npm run clean",
18
67
  "watch": "run-p \"build:* -- --watch\"",
19
- "test": "jest"
68
+ "test": "jest",
69
+ "lint": "eslint --cache ."
20
70
  },
21
- "keywords": [],
22
- "author": "Bjørge Næss <bjoerge@gmail.com>",
23
- "license": "MIT",
24
71
  "dependencies": {
25
72
  "observable-callback": "^1.0.1",
26
73
  "use-sync-external-store": "^1.2.0"
27
74
  },
28
- "peerDependencies": {
29
- "react": "^16.8 || ^17 || ^18",
30
- "rxjs": "^6.5 || ^7"
31
- },
32
75
  "devDependencies": {
76
+ "@sanity/semantic-release-preset": "^2.0.1",
33
77
  "@testing-library/dom": "^8.14.0",
34
78
  "@testing-library/react": "^13.3.0",
35
79
  "@types/jest": "^28.1.4",
80
+ "@types/node": "^18.8.2",
36
81
  "@types/react": "^18.0.12",
37
82
  "@types/react-dom": "^18.0.5",
38
83
  "@types/use-sync-external-store": "^0.0.3",
@@ -42,29 +87,66 @@
42
87
  "eslint-config-prettier": "^8.1.0",
43
88
  "eslint-plugin-prettier": "^3.3.1",
44
89
  "eslint-plugin-react": "^7.22.0",
90
+ "eslint-plugin-react-hooks": "^4.6.0",
45
91
  "jest": "^28.1.2",
46
92
  "jest-environment-jsdom": "^28.1.2",
47
93
  "jsdom": "^20.0.0",
48
94
  "npm-run-all": "^4.1.5",
49
- "prettier": "2.2.1",
95
+ "prettier": "^2.7.1",
96
+ "prettier-plugin-packagejson": "^2.3.0",
50
97
  "react": "^18.2.0",
51
98
  "react-dom": "^18.2.0",
52
99
  "react-test-renderer": "^18.2.0",
53
100
  "rimraf": "^3.0.2",
54
101
  "rxjs": "^6.5.5",
55
102
  "ts-jest": "^28.0.5",
56
- "typescript": "^4.2.2"
103
+ "typescript": "4.7.4"
57
104
  },
58
- "repository": {
59
- "type": "git",
60
- "url": "git+https://github.com/bjoerge/react-rx.git"
105
+ "overrides": {
106
+ "@babel/plugin-proposal-class-properties": "7.10.1",
107
+ "@babel/plugin-proposal-optional-chaining": "7.10.3",
108
+ "@babel/plugin-transform-typescript": "7.10.3",
109
+ "@babel/preset-env": "7.10.3",
110
+ "@babel/preset-react": "7.10.1",
111
+ "@babel/preset-typescript": "7.10.1",
112
+ "@babel/standalone": "7.10.3",
113
+ "@mdx-js/loader": "1.6.22",
114
+ "@mdx-js/mdx": "1.6.22",
115
+ "@mdx-js/react": "1.6.22",
116
+ "@types/codemirror": "0.0.96",
117
+ "@types/history": "4.7.6",
118
+ "@types/mdx-js__react": "1.5.5",
119
+ "@webhotelier/webpack-fast-refresh": "5.0.1",
120
+ "babel-loader": "8.1.0",
121
+ "babel-plugin-static-fs": "3.0.0",
122
+ "babel-plugin-styled-components": "1.10.7",
123
+ "bezier-easing": "2.1.0",
124
+ "clean-webpack-plugin": "3.0.0",
125
+ "codemirror": "5.55.0",
126
+ "copy-webpack-plugin": "6.0.2",
127
+ "css-loader": "3.6.0",
128
+ "date-fns": "2.14.0",
129
+ "github-slugger": "1.3.0",
130
+ "history": "5.0.0",
131
+ "html-loader": "1.1.0",
132
+ "html-webpack-plugin": "4.3.0",
133
+ "markdown-toc": "1.2.0",
134
+ "react-codemirror2": "7.2.1",
135
+ "react-css-burger": "0.2.0",
136
+ "react-refresh": "0.8.3",
137
+ "remark-emoji": "2.1.0",
138
+ "remark-slug": "6.0.0",
139
+ "style-loader": "1.2.1",
140
+ "ts-loader": "7.0.5",
141
+ "ts-node": "8.10.2",
142
+ "tsconfig-paths": "3.11.0",
143
+ "webpack": "4.64.0",
144
+ "webpack-cli": "3.3.12",
145
+ "webpack-dev-server": "3.11.2"
61
146
  },
62
- "bugs": {
63
- "url": "https://github.com/bjoerge/react-rx/issues"
147
+ "peerDependencies": {
148
+ "react": "^16.8 || ^17 || ^18",
149
+ "rxjs": "^6.5 || ^7"
64
150
  },
65
- "homepage": "https://react-rx.dev",
66
- "workspaces": [
67
- "website",
68
- "."
69
- ]
151
+ "es2015": "dist/es2015/index.js"
70
152
  }
@@ -0,0 +1,79 @@
1
+ import {BehaviorSubject, Observable, of} from 'rxjs'
2
+ import {useObservable} from '../useObservable'
3
+ import {createElement, Fragment, StrictMode, useEffect} from 'react'
4
+ import {act, render} from '@testing-library/react'
5
+ import {useAsObservable} from '../useAsObservable'
6
+
7
+ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
8
+
9
+ // NOTE: Jest runs NODE_ENV=test by default, which enables development flags for React
10
+
11
+ test('Strict mode should trigger double mount effects and re-renders', async () => {
12
+ const subject = new BehaviorSubject(0)
13
+ const observable = subject.asObservable()
14
+
15
+ const returnedValues: unknown[] = []
16
+ let mountCount = 0
17
+ function ObservableComponent() {
18
+ useEffect(() => {
19
+ mountCount++
20
+ }, [])
21
+ const observedValue = useObservable(observable)
22
+ returnedValues.push(observedValue)
23
+ return createElement(Fragment, null, observedValue)
24
+ }
25
+
26
+ const {rerender} = render(createElement(StrictMode, null, createElement(ObservableComponent)))
27
+ expect(mountCount).toEqual(2)
28
+
29
+ expect(returnedValues).toEqual([0, 0])
30
+
31
+ await wait(10)
32
+ act(() => subject.next(1))
33
+ expect(returnedValues).toEqual([0, 0, 1, 1])
34
+
35
+ act(() => subject.next(2))
36
+ expect(returnedValues).toEqual([0, 0, 1, 1, 2, 2])
37
+
38
+ expect(mountCount).toEqual(2)
39
+ })
40
+
41
+ test('Strict mode should unsubscribe the source observable on unmount', () => {
42
+ let subscribed = false
43
+ const observable = new Observable(() => {
44
+ subscribed = true
45
+ return () => {
46
+ subscribed = false
47
+ }
48
+ })
49
+
50
+ function ObservableComponent() {
51
+ useObservable(observable)
52
+ return createElement(Fragment, null)
53
+ }
54
+
55
+ const {rerender} = render(createElement(StrictMode, null, createElement(ObservableComponent)))
56
+ expect(subscribed).toBe(true)
57
+ rerender(createElement(StrictMode, null, createElement('div')))
58
+ expect(subscribed).toBe(false)
59
+ })
60
+
61
+ test('useAsObservable should work in strict mode', async () => {
62
+ const returnedValues: unknown[] = []
63
+ function ObservableComponent(props: {count: number}) {
64
+ const count$ = useAsObservable(props.count)
65
+ const count = useObservable(count$)
66
+ returnedValues.push(count)
67
+ return createElement(Fragment, null, 'ok')
68
+ }
69
+
70
+ const {rerender} = render(
71
+ createElement(StrictMode, null, createElement(ObservableComponent, {count: 0})),
72
+ )
73
+
74
+ expect(returnedValues).toEqual([0, 0])
75
+
76
+ rerender(createElement(StrictMode, null, createElement(ObservableComponent, {count: 1})))
77
+
78
+ expect(returnedValues).toEqual([0, 0, 0, 0, 1, 1])
79
+ })
@@ -0,0 +1,94 @@
1
+ import {useAsObservable} from '../useAsObservable'
2
+ import React from 'react'
3
+ import {renderHook} from '@testing-library/react'
4
+ import {Observable} from 'rxjs'
5
+
6
+ test('the returned observable should receive a new value when component is rendered with a new value', () => {
7
+ const receivedValues: string[] = []
8
+ const {unmount, rerender} = renderHook(
9
+ (props: {value: string}) => {
10
+ const observable = useAsObservable(props.value)
11
+
12
+ React.useEffect(() => {
13
+ const subscription = observable.subscribe(v => {
14
+ receivedValues.push(v)
15
+ })
16
+ return () => {
17
+ subscription.unsubscribe()
18
+ }
19
+ }, [])
20
+ },
21
+ {initialProps: {value: 'initial'}},
22
+ )
23
+
24
+ rerender({value: 'rerender'})
25
+ rerender({value: 'rerender again'})
26
+
27
+ expect(receivedValues).toEqual(['initial', 'rerender', 'rerender again'])
28
+
29
+ unmount()
30
+ })
31
+
32
+ test('the returned observable should *not* receive a new value when component is rendered with an unchanged value', () => {
33
+ const receivedValues: string[] = []
34
+ const {unmount, rerender} = renderHook(
35
+ (props: {value: string}) => {
36
+ const observable = useAsObservable(props.value)
37
+
38
+ React.useEffect(() => {
39
+ const subscription = observable.subscribe(v => {
40
+ receivedValues.push(v)
41
+ })
42
+ return () => {
43
+ subscription.unsubscribe()
44
+ }
45
+ }, [])
46
+ },
47
+ {initialProps: {value: 'some value'}},
48
+ )
49
+
50
+ rerender({value: 'some value'})
51
+ rerender({value: 'some value'})
52
+
53
+ expect(receivedValues).toEqual(['some value'])
54
+
55
+ unmount()
56
+ })
57
+
58
+ test('the returned observable should have the same identity across multiple re-renders/hook calls', () => {
59
+ const returnValues: Observable<unknown>[] = []
60
+ const {unmount, rerender} = renderHook(() => {
61
+ returnValues.push(useAsObservable('render'))
62
+ })
63
+
64
+ rerender()
65
+ rerender()
66
+
67
+ const [initial, firstRerender, secondRerender] = returnValues
68
+ expect(initial).toBe(firstRerender)
69
+ expect(firstRerender).toBe(secondRerender)
70
+ unmount()
71
+ })
72
+
73
+ test('the returned observable should complete on unmount', () => {
74
+ let didComplete = false
75
+ const {unmount, rerender} = renderHook(() => {
76
+ const observable = useAsObservable('render')
77
+
78
+ React.useEffect(() => {
79
+ const subscription = observable.subscribe({
80
+ complete: () => {
81
+ didComplete = true
82
+ },
83
+ })
84
+ return () => {
85
+ subscription.unsubscribe()
86
+ }
87
+ }, [])
88
+ })
89
+ expect(didComplete).toBe(false)
90
+ rerender()
91
+ expect(didComplete).toBe(false)
92
+ unmount()
93
+ expect(didComplete).toBe(true)
94
+ })
@@ -0,0 +1,145 @@
1
+ import {act, render, renderHook} from '@testing-library/react'
2
+ import {useObservable} from '../useObservable'
3
+ import {asyncScheduler, Observable, of, scheduled, Subject, timer} from 'rxjs'
4
+ import {mapTo} from 'rxjs/operators'
5
+ import {createElement, Fragment} from 'react'
6
+
7
+ test('should subscribe immediately on component mount and unsubscribe on component unmount', () => {
8
+ let subscribed = false
9
+ const observable = new Observable(() => {
10
+ subscribed = true
11
+ return () => {
12
+ subscribed = false
13
+ }
14
+ })
15
+
16
+ expect(subscribed).toBe(false)
17
+
18
+ const {unmount} = renderHook(() => useObservable(observable))
19
+ expect(subscribed).toBe(true)
20
+
21
+ unmount()
22
+ expect(subscribed).toBe(false)
23
+ })
24
+
25
+ test('should only subscribe once when given same observable on re-renders', () => {
26
+ let subscriptionCount = 0
27
+ const observable = new Observable(() => {
28
+ subscriptionCount++
29
+ })
30
+
31
+ expect(subscriptionCount).toBe(0)
32
+
33
+ const {unmount, rerender} = renderHook(() => useObservable(observable))
34
+ expect(subscriptionCount).toBe(1)
35
+ rerender()
36
+ expect(subscriptionCount).toBe(1)
37
+ unmount()
38
+
39
+ renderHook(() => useObservable(observable))
40
+ expect(subscriptionCount).toBe(2)
41
+ })
42
+
43
+ test('should not return undefined during render if initial value is given', () => {
44
+ const observable = timer(100).pipe(mapTo('emitted value'))
45
+
46
+ const returnedValues: unknown[] = []
47
+ function ObservableComponent() {
48
+ const observedValue = useObservable(observable, 'initial value')
49
+ returnedValues.push(observedValue)
50
+ return createElement(Fragment, null, observedValue)
51
+ }
52
+ render(createElement(ObservableComponent))
53
+ expect(returnedValues).toEqual(expect.arrayContaining(['initial value']))
54
+ })
55
+
56
+ test('should not return undefined during render if observable is sync', () => {
57
+ const observable = of('initial value')
58
+
59
+ const returnedValues: unknown[] = []
60
+ function ObservableComponent() {
61
+ const observedValue = useObservable(observable)
62
+ returnedValues.push(observedValue)
63
+ return createElement(Fragment, null, observedValue)
64
+ }
65
+ render(createElement(ObservableComponent))
66
+ expect(returnedValues).toEqual(expect.arrayContaining(['initial value']))
67
+ })
68
+
69
+ test('should return undefined during first render if observable is async', () => {
70
+ const observable = scheduled('async value', asyncScheduler)
71
+
72
+ const returnedValues: unknown[] = []
73
+ function ObservableComponent() {
74
+ const observedValue = useObservable(observable)
75
+ returnedValues.push(observedValue)
76
+ return createElement(Fragment, null, observedValue)
77
+ }
78
+ render(createElement(ObservableComponent))
79
+ expect(returnedValues).toEqual(expect.arrayContaining([undefined]))
80
+ })
81
+
82
+ test('should have sync values from an observable as initial value', () => {
83
+ const observable = of('something sync')
84
+ const {result} = renderHook(() => useObservable(observable))
85
+ expect(result.current).toBe('something sync')
86
+ })
87
+
88
+ test('should have undefined as initial value from delayed observables', () => {
89
+ const {result, unmount} = renderHook(() =>
90
+ useObservable(scheduled('something async', asyncScheduler)),
91
+ )
92
+ expect(result.current).toBeUndefined()
93
+ unmount()
94
+ })
95
+
96
+ test('should have passed initialValue as initial value from delayed observables', () => {
97
+ const {result, unmount} = renderHook(() =>
98
+ useObservable(scheduled('something async', asyncScheduler), 'initial'),
99
+ )
100
+ expect(result.current).toBe('initial')
101
+ unmount()
102
+ })
103
+
104
+ test('should update with values from observables', () => {
105
+ const values$ = new Subject<string>()
106
+ const {result, unmount} = renderHook(() => useObservable(values$))
107
+
108
+ expect(result.current).toBe(undefined)
109
+
110
+ act(() => values$.next('something'))
111
+ expect(result.current).toBe('something')
112
+
113
+ act(() => values$.next('otherthing'))
114
+ expect(result.current).toBe('otherthing')
115
+ unmount()
116
+ })
117
+
118
+ test('should re-subscribe when receiving a new observable', () => {
119
+ const first$ = new Subject<string>()
120
+ const second$ = new Subject<string>()
121
+
122
+ let current$ = first$
123
+
124
+ const {result, rerender, unmount} = renderHook(() => useObservable(current$, '!!initial!!'))
125
+
126
+ act(() => first$.next('first 1'))
127
+ expect(result.current).toBe('first 1')
128
+
129
+ current$ = second$
130
+
131
+ rerender()
132
+
133
+ // since observable #2 hasn't emitted a value yet, we should use the initial value
134
+ expect(result.current).toBe('!!initial!!')
135
+
136
+ // Now we should be subscribed to second$ and it's emission should be returned
137
+ act(() => second$.next('second 1'))
138
+ expect(result.current).toBe('second 1')
139
+
140
+ // we should no longer be subscribed to the first and ignore any emissions
141
+ act(() => first$.next('first 2'))
142
+ expect(result.current).toBe('second 1')
143
+
144
+ unmount()
145
+ })
@@ -6,5 +6,6 @@ const getDisplayName = (Component: any) => {
6
6
  return (Component && (Component.displayName || Component.name)) || 'Unknown'
7
7
  }
8
8
 
9
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/ban-types
9
10
  export const wrapDisplayName = (BaseComponent: Function, wrapperName: string) =>
10
11
  `${wrapperName}(${getDisplayName(BaseComponent)})`
@@ -1,3 +1,4 @@
1
+ /* eslint-disable react-hooks/rules-of-hooks */
1
2
  import {Observable} from 'rxjs'
2
3
  import {wrapDisplayName} from './displayName'
3
4
  import {useAsObservable} from './useAsObservable'
@@ -26,6 +27,7 @@ function fromComponent<Props>(component: Component<Props>): FunctionComponent<Pr
26
27
  return wrappedComponent
27
28
  }
28
29
 
30
+ // eslint-disable-next-line @typescript-eslint/ban-types
29
31
  function fromObservable<Props>(input$: Observable<ReactNode>): FunctionComponent<{}> {
30
32
  return function ReactiveComponent() {
31
33
  return createElement(Fragment, null, useObservable<ReactNode>(input$))
@@ -47,6 +49,7 @@ type ForwardRefComponent<RefType, Props> = (
47
49
  ref: Ref<RefType>,
48
50
  ) => Observable<ReactNode>
49
51
 
52
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/ban-types
50
53
  export function forwardRef<RefType, Props = {}>(component: ForwardRefComponent<RefType, Props>) {
51
54
  const wrappedComponent = reactForwardRef((props: Props, ref: Ref<RefType>) => {
52
55
  return createElement(