diff --git a/README.md b/README.md index b077e353..6dd5328a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ declare function useObservable(sourceFactory: () => Observable): T | null declare function useObservable(sourceFactory: () => Observable, initialState: T): T -declare function useObservable(sourceFactory: (props$: Observable) => Observable, initialState: T, inputs: U): T +declare function useObservable(sourceFactory: (inputs$: Observable) => Observable, initialState: T, inputs: U): T ``` #### Examples: @@ -133,7 +133,7 @@ import { of } from 'rxjs' import { map } from 'rxjs/operators' function App(props: { foo: number }) { - const value = useObservable((props$) => props$.pipe( + const value = useObservable((inputs$) => inputs$.pipe( map(([val]) => val + 1), ), 200, [props.foo]) return ( diff --git a/playground/index.tsx b/playground/index.tsx index b35a8e79..e9a31d71 100644 --- a/playground/index.tsx +++ b/playground/index.tsx @@ -6,17 +6,17 @@ import { exhaustMap, mapTo, scan, switchMap } from 'rxjs/operators' import { useObservable } from '../src/use-observable' import { useEventCallback } from '../src/use-event-callback' -const mockBackendRequest = (event$: Observable>) => +const mockBackendRequest = (event$: Observable>) => event$.pipe( exhaustMap(() => timer(1000).pipe(mapTo(100))), scan((acc, cur) => acc + cur, 0), ) function IntervalValue(props: { interval: number }) { - const [clickCallback, value] = useEventCallback(mockBackendRequest, 0) + const [clickCallback, value] = useEventCallback(mockBackendRequest, 0, []) const intervalValue = useObservable( - (props$) => - props$.pipe( + (inputs$, _) => + inputs$.pipe( switchMap(([intervalTime]) => interval(intervalTime)), scan((acc) => acc + 1, 0), ), diff --git a/src/__test__/use-event-callback.spec.tsx b/src/__test__/use-event-callback.spec.tsx index a5a948fb..56e2e662 100644 --- a/src/__test__/use-event-callback.spec.tsx +++ b/src/__test__/use-event-callback.spec.tsx @@ -1,6 +1,6 @@ import React from 'react' import { Observable, of, Observer } from 'rxjs' -import { mapTo, delay } from 'rxjs/operators' +import { mapTo, delay, withLatestFrom, map } from 'rxjs/operators' import { create } from 'react-test-renderer' import * as Sinon from 'sinon' @@ -42,7 +42,7 @@ describe('useEventCallback specs', () => { expect(find(testRenderer.root, 'h1').children).toEqual([`${value}`]) }) - it('should trigger handler async callback', () => { + it('should trigger handle async callback', () => { const timer = Sinon.useFakeTimers() const timeToDelay = 200 const value = 1 @@ -63,7 +63,7 @@ describe('useEventCallback specs', () => { timer.restore() }) - it('should handler the initial value', () => { + it('should handle the initial value', () => { const timer = Sinon.useFakeTimers() const initialValue = 1000 const value = 1 @@ -88,6 +88,89 @@ describe('useEventCallback specs', () => { timer.restore() }) + it('should handle the state changed', () => { + const timer = Sinon.useFakeTimers() + const initialValue = 1000 + const value = 1 + const timeToDelay = 200 + const factory = (event$: Observable>, state$: Observable) => + event$.pipe( + withLatestFrom(state$), + map(([_, state]) => { + return state + value + }), + delay(timeToDelay), + ) + function Fixture() { + const [clickCallback, stateValue] = useEventCallback(factory, initialValue) + + return ( + <> +

{stateValue}

+ + + ) + } + const fixtureNode = + const testRenderer = create(fixtureNode) + expect(find(testRenderer.root, 'h1').children).toEqual([`${initialValue}`]) + testRenderer.update(fixtureNode) + const button = find(testRenderer.root, 'button') + button.props.onClick() + timer.tick(timeToDelay) + testRenderer.update(fixtureNode) + expect(find(testRenderer.root, 'h1').children).toEqual([`${initialValue + value}`]) + button.props.onClick() + timer.tick(timeToDelay) + testRenderer.update(fixtureNode) + expect(find(testRenderer.root, 'h1').children).toEqual([`${initialValue + value * 2}`]) + timer.restore() + }) + + it('should handle the inputs changed', () => { + const timer = Sinon.useFakeTimers() + const initialValue = 1000 + const value = 1 + const timeToDelay = 200 + const factory = ( + event$: Observable>, + inputs$: Observable, + _state$: Observable, + ) => + event$.pipe( + withLatestFrom(inputs$), + map(([_, [count]]) => { + return value + count + }), + delay(timeToDelay), + ) + function Fixture(props: { count: number }) { + const [clickCallback, stateValue] = useEventCallback(factory, initialValue, [props.count]) + + return ( + <> +

{stateValue}

+ + + ) + } + const fixtureNode = + const testRenderer = create(fixtureNode) + expect(find(testRenderer.root, 'h1').children).toEqual([`${initialValue}`]) + testRenderer.update(fixtureNode) + const button = find(testRenderer.root, 'button') + button.props.onClick() + timer.tick(timeToDelay) + testRenderer.update(fixtureNode) + expect(find(testRenderer.root, 'h1').children).toEqual([`${value + 1}`]) + testRenderer.update() + button.props.onClick() + timer.tick(timeToDelay) + testRenderer.update() + expect(find(testRenderer.root, 'h1').children).toEqual([`${value + 4}`]) + timer.restore() + }) + it('should call teardown logic after unmount', () => { const spy = Sinon.spy() const Fixture = createFixture( diff --git a/src/__test__/use-observable.spec.tsx b/src/__test__/use-observable.spec.tsx index bec9211d..ec5a313c 100644 --- a/src/__test__/use-observable.spec.tsx +++ b/src/__test__/use-observable.spec.tsx @@ -1,21 +1,21 @@ import React from 'react' import { create } from 'react-test-renderer' import * as Sinon from 'sinon' -import { of, Observable, Observer } from 'rxjs' +import { of, Observable, Observer, Subject } from 'rxjs' import { find } from './find' import { useObservable } from '../use-observable' -import { tap } from 'rxjs/operators' +import { tap, withLatestFrom, map } from 'rxjs/operators' describe('useObservable specs', () => { - let timer: Sinon.SinonFakeTimers + let fakeTimer: Sinon.SinonFakeTimers beforeEach(() => { - timer = Sinon.useFakeTimers() + fakeTimer = Sinon.useFakeTimers() }) afterEach(() => { - timer.restore() + fakeTimer.restore() }) it('should get value from sync Observable', () => { @@ -75,10 +75,51 @@ describe('useObservable specs', () => { expect(spy.callCount).toBe(1) }) + it('should emit changed states in observableFactory', () => { + const spy = Sinon.spy() + const initialValue = 1000 + const source$ = new Subject() + function Fixture() { + const value = useObservable((state$: Observable) => + source$.pipe( + withLatestFrom(state$), + map(([intervalValue, state]) => { + if (state) { + return intervalValue + state + } + return intervalValue + }), + tap(spy), + ), + ) + return ( + <> +

{value}

+ + ) + } + + const testRenderer = create() + expect(spy.callCount).toBe(0) + expect(find(testRenderer.root, 'h1').children).toEqual([]) + testRenderer.update() + source$.next(initialValue) + expect(spy.callCount).toBe(1) + expect(spy.args[0]).toEqual([initialValue]) + expect(find(testRenderer.root, 'h1').children).toEqual([`${initialValue}`]) + + testRenderer.update() + const secondValue = 2000 + source$.next(secondValue) + expect(spy.callCount).toBe(2) + expect(spy.args[1]).toEqual([initialValue + secondValue]) + expect(find(testRenderer.root, 'h1').children).toEqual([`${initialValue + secondValue}`]) + }) + it('should emit changed props in observableFactory', () => { const spy = Sinon.spy() function Fixture(props: { foo: number; bar: string; baz: any }) { - const value = useObservable((props$: Observable<[number, any]>) => props$.pipe(tap(spy)), null, [ + const value = useObservable((inputs$: Observable<[number, any]>) => inputs$.pipe(tap(spy)), null, [ props.foo, props.baz, ] as any) diff --git a/src/use-event-callback.ts b/src/use-event-callback.ts index cd4fc29d..e1b2c1e9 100644 --- a/src/use-event-callback.ts +++ b/src/use-event-callback.ts @@ -1,34 +1,76 @@ -import { useEffect, useState, SyntheticEvent } from 'react' -import { Observable, Subject, noop } from 'rxjs' +import { useEffect, useMemo, useState, SyntheticEvent } from 'react' +import { Observable, BehaviorSubject, Subject, noop } from 'rxjs' -export type EventCallbackState = [((e: SyntheticEvent) => void) | typeof noop, U] +export type VoidAsNull = T extends void ? null : T -export type EventCallback = (eventSource$: Observable>) => Observable +export type EventCallbackState<_T, E, U, I = void> = [ + (e: E) => void, + [U extends void ? null : U, BehaviorSubject, BehaviorSubject] +] +export type ReturnedState = [EventCallbackState[0], EventCallbackState[1][0]] -export function useEventCallback(callback: EventCallback): EventCallbackState -export function useEventCallback(callback: EventCallback, initialState: U): EventCallbackState +export type EventCallback<_T, E, U, I> = I extends void + ? (eventSource$: Observable, state$: Observable) => Observable + : (eventSource$: Observable, inputs$: Observable, state$: Observable) => Observable -export function useEventCallback( - callback: EventCallback, +export function useEventCallback, U = void>( + callback: EventCallback, +): ReturnedState +export function useEventCallback, U = void>( + callback: EventCallback, + initialState: U, +): ReturnedState +export function useEventCallback, U = void, I = void>( + callback: EventCallback, + initialState: U, + inputs: I, +): ReturnedState + +export function useEventCallback, U = void, I = void>( + callback: EventCallback, initialState?: U, -): EventCallbackState { - const initialValue = typeof initialState !== 'undefined' ? initialState : null - const [state, setState] = useState>([noop, initialValue]) + inputs?: I, +): ReturnedState { + const initialValue = (typeof initialState !== 'undefined' ? initialState : null) as VoidAsNull + const inputSubject$ = new BehaviorSubject(typeof inputs === 'undefined' ? null : inputs) + const stateSubject$ = new BehaviorSubject(initialValue) + const [state, setState] = useState(initialValue) + const [returnedCallback, setEventCallback] = useState<(e: E) => void>(noop) + const [state$] = useState(stateSubject$) + const [inputs$] = useState(inputSubject$) + + useMemo(() => { + inputs$.next(inputs!) + }, ((inputs as unknown) as ReadonlyArray) || []) + useEffect( () => { - const event$ = new Subject>() - function eventCallback(e: SyntheticEvent) { + const event$ = new Subject() + function eventCallback(e: E) { return event$.next(e) } - setState([eventCallback, initialValue]) - const value$ = callback(event$) + setState(initialValue) + setEventCallback(() => eventCallback) + let value$: Observable + + if (!inputs) { + value$ = (callback as EventCallback)(event$, state$ as Observable) + } else { + value$ = (callback as any)(event$, inputs$ as Observable, state$ as Observable) + } const subscription = value$.subscribe((value) => { - setState([eventCallback, value]) + state$.next(value) + setState(value as VoidAsNull) }) - return () => subscription.unsubscribe() + return () => { + subscription.unsubscribe() + state$.complete() + inputs$.complete() + event$.complete() + } }, - [0], // immutable forever + [], // immutable forever ) - return state + return [returnedCallback, state] } diff --git a/src/use-observable.ts b/src/use-observable.ts index 73ef35fe..2507acd6 100644 --- a/src/use-observable.ts +++ b/src/use-observable.ts @@ -2,42 +2,47 @@ import { Observable, BehaviorSubject } from 'rxjs' import { useState, useEffect, useMemo } from 'react' export type InputFactory = U extends undefined - ? () => Observable - : (props$: Observable) => Observable + ? (state$: Observable) => Observable + : (inputs$: Observable, state$: Observable) => Observable export function useObservable(inputFactory: InputFactory): T | null export function useObservable(inputFactory: InputFactory, initialState: T): T -export function useObservable>( - inputFactory: InputFactory, - initialState: T, - inputs: U, -): T +export function useObservable(inputFactory: InputFactory, initialState: T, inputs: U): T -export function useObservable | undefined>( - inputFactory: InputFactory, - initialState?: T, - inputs?: U, -): T | null { - const [inputs$] = useState(new BehaviorSubject(inputs)) +export function useObservable(inputFactory: InputFactory, initialState?: T, inputs?: U): T | null { + const stateSubject$ = new BehaviorSubject(initialState) + const inputSubject$ = new BehaviorSubject(inputs) const [state, setState] = useState(typeof initialState !== 'undefined' ? initialState : null) + const [state$] = useState(stateSubject$) + const [inputs$] = useState(inputSubject$) + + useMemo(() => { + inputs$.next(inputs) + }, ((inputs as unknown) as ReadonlyArray) || []) useEffect( () => { - const output$ = (inputFactory as (inputs$?: Observable) => Observable)( - typeof inputs !== 'undefined' ? inputs$ : void 0, - ) - const subscription = output$.subscribe((value) => setState(value)) - return () => subscription.unsubscribe() + let output$: BehaviorSubject + if (inputs) { + output$ = (inputFactory as ( + inputs$: Observable, + state$: Observable, + ) => Observable)(inputs$, state$) as BehaviorSubject + } else { + output$ = (inputFactory as (state$: Observable) => Observable)(state$) as BehaviorSubject + } + const subscription = output$.subscribe((value) => { + state$.next(value) + setState(value) + }) + return () => { + subscription.unsubscribe() + inputs$.complete() + state$.complete() + } }, [], // immutable forever ) - useMemo( - () => { - inputs$.next(inputs) - }, - (inputs || []) as ReadonlyArray, - ) - return state }