Skip to content

Commit

Permalink
feat: add useObservable (#22)
Browse files Browse the repository at this point in the history
* feat: add useObservable

* chore: change error log
  • Loading branch information
wzhudev authored May 14, 2024
1 parent bc1388c commit b894be7
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 10 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/wzhudev/squirrel/master/src/schema/package.schema.json",
"name": "@wendellhu/redi",
"version": "0.14.0",
"version": "0.15.0",
"description": "A dependency library for TypeScript and JavaScript, along with a binding for React.",
"scripts": {
"test": "vitest run",
Expand All @@ -20,6 +20,7 @@
"devDependencies": {
"@testing-library/dom": "^8.5.0",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/node": "^20.11.7",
"@types/react": "^18.2.48",
"@vitest/coverage-istanbul": "^1.5.0",
Expand Down
39 changes: 39 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 78 additions & 5 deletions src/react-bindings/reactRx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ import React, {
Context,
useRef,
} from 'react'
import { BehaviorSubject, Observable } from 'rxjs'
import { BehaviorSubject, Observable, Subscription } from 'rxjs'

import { RediError } from '@wendellhu/redi'

/**
* unwrap an observable value, return it to the component for rendering, and
* trigger re-render when value changes
*
* **IMPORTANT**. Parent and child components should not subscribe to the same
* **IMPORTANT**. Parent and child components better not subscribe to the same
* observable, otherwise unnecessary re-render would be triggered. Instead, the
* top-most component should subscribe and pass value of the observable to
* its offspring, by props or context.
* its offspring, by props or context. Please consider using `useDependencyContext` and
* `useDependencyContextValue` in this case.
*
* If you have to do that, consider using `useDependencyContext` and
* `useDependencyContextValue` instead.
* @deprecated Please use `useObservable` instead.
*/
export function useDependencyValue<T>(
depValue$: Observable<T>,
Expand All @@ -43,6 +43,79 @@ export function useDependencyValue<T>(
return value
}

type ObservableOrFn<T> = Observable<T> | (() => Observable<T>);
type Nullable<T> = T | undefined | null;

function unwrap<T>(o: ObservableOrFn<T>): Observable<T> {
if (typeof o === 'function') {
return o();
}

return o;
}

export function useObservable<T>(observable: Nullable<ObservableOrFn<T>>): T | undefined
export function useObservable<T>(observable: Nullable<ObservableOrFn<T>>, defaultValue: T): T
export function useObservable<T>(observable: Nullable<ObservableOrFn<T>>, defaultValue: undefined, shouldHaveSyncValue: true, deps?: any[]): T
export function useObservable<T>(observable: Nullable<ObservableOrFn<T>>, defaultValue?: undefined, shouldHaveSyncValue?: true, deps?: any[]): T | undefined
/**
* Subscribe to an observable and return its value. The component will re-render when the observable emits a new value.
*
* @param observable An observable or a function that returns an observable
* @param defaultValue The default value of the observable. It the `observable` can omit an initial value, this value will be neglected.
* @param shouldHaveSyncValue If the observable should have a sync value. If it does not have a sync value, an error will be thrown.
* @param deps A dependency array to decide if we should re-subscribe when the `observable` is a function.
* @returns
*/
export function useObservable<T>(observable: Nullable<ObservableOrFn<T>>, defaultValue?: undefined, shouldHaveSyncValue?: true, deps?: any[]): T | undefined {
if (typeof observable === 'function' && !deps) {
throw new RediError("Expected deps to be provided when observable is a function!")
}

const observableRef = useRef<Observable<T> | null>(null);
const initializedRef = useRef<boolean>(false);

// eslint-disable-next-line react-hooks/exhaustive-deps
const destObservable = useMemo(() => observable, [...(typeof deps !== 'undefined' ? deps : [observable])]);

// This state is only for trigger React to re-render. We do not use `setValue` directly because it may cause
// memory leaking.
const [_, setRenderCounter] = useState<number>(0);

const valueRef = useRef<T | undefined>((() => {
let innerDefaultValue: T | undefined;
if (destObservable) {
const sub = unwrap(destObservable).subscribe((value) => {
initializedRef.current = true;
innerDefaultValue = value;
});

sub.unsubscribe();
}

return innerDefaultValue ?? defaultValue;
})());

useEffect(() => {
let subscription: Subscription | null = null;
if (destObservable) {
observableRef.current = unwrap(destObservable);
subscription = observableRef.current.subscribe((value) => {
valueRef.current = value;
setRenderCounter((prev) => prev + 1);
});
}

return () => subscription?.unsubscribe();
}, [destObservable]);

if (shouldHaveSyncValue && !initializedRef.current) {
throw new Error('Expect `shouldHaveSyncValue` but not getting a sync value!');
}

return valueRef.current;
}

/**
* subscribe to a signal that emits whenever data updates and re-render
*
Expand Down
85 changes: 81 additions & 4 deletions test/rx.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
*/

import { describe, afterEach, it, expect } from 'vitest'
import { act, render } from '@testing-library/react'
import React, { Component } from 'react'
import { BehaviorSubject, interval, Subject } from 'rxjs'
import { act, render, renderHook } from '@testing-library/react'
import React, { Component, useState } from 'react'
import { BehaviorSubject, interval, Observable, of, Subject } from 'rxjs'
import { scan, startWith } from 'rxjs/operators'

import { IDisposable } from '@wendellhu/redi'
Expand All @@ -16,12 +16,13 @@ import {
useDependencyContext,
useDependencyContextValue,
connectDependencies,
useObservable,
} from '@wendellhu/redi/react-bindings'

import { TEST_ONLY_clearKnownIdentifiers } from '../src/decorators'
import { expectToThrow } from './util/expectToThrow'

describe('rx', () => {
describe('test legacy rxjs utils', () => {
afterEach(() => {
TEST_ONLY_clearKnownIdentifiers()
})
Expand Down Expand Up @@ -272,3 +273,79 @@ describe('rx', () => {
expect(container.firstChild!.textContent).toBe('3')
})
})

describe('test "useObservable"', () => {
it('should return undefined when no initial value is provided', () => {
const observable: Observable<boolean> | undefined = undefined;

const { result } = renderHook(() => useObservable<boolean>(observable));
expect(result.current).toBeUndefined();
});

it('should return the initial value when provided', () => {
const observable: Observable<boolean> | undefined = undefined;

const { result } = renderHook(() => useObservable<boolean>(observable, true));
expect(result.current).toBeTruthy();
});

it('should return the initial value when provided synchronously', () => {
const observable: Observable<boolean> = of(true);

const { result } = renderHook(() => useObservable<boolean>(observable));
expect(result.current).toBeTruthy();
});

function useTestUseObservableBed() {
const observable = new Subject<boolean>();
const result = useObservable(observable, undefined);

return {
observable,
result,
};
}

it('should emit new value when observable emits', () => {
const { result } = renderHook(() => useTestUseObservableBed());

expect(result.current.result).toBeUndefined();

act(() => result.current.observable.next(true));
expect(result.current.result).toBeTruthy();

act(() => result.current.observable.next(false));
expect(result.current.result).toBeFalsy();
});

function useTestSwitchObservableBed() {
const [observable, setObservable] = useState<Observable<boolean> | undefined>(undefined);
const result = useObservable(observable);

return {
result,
observable,
setObservable,
};
}

it('should emit when passing new observable to the hook', () => {
const { result } = renderHook(() => useTestSwitchObservableBed());

expect(result.current.result).toBeUndefined();

act(() => result.current.setObservable(of(true)));
expect(result.current.result).toBeTruthy();

act(() => result.current.setObservable(of(false)));
expect(result.current.result).toBeFalsy();
});

it('should support a callback function returns an observable', () => {
// const { result } = renderHook(() => useObservable(() => of(true)));
// This line above would cause infinite look. Pass `deps` to fix the problem.
const { result } = renderHook(() => useObservable(() => of(true), undefined, true, []));

expect(result.current).toBeTruthy();
});
});

0 comments on commit b894be7

Please sign in to comment.