diff --git a/src/internal/BehaviorObservable.ts b/src/internal/BehaviorObservable.ts index acfd372e..f62a1c8e 100644 --- a/src/internal/BehaviorObservable.ts +++ b/src/internal/BehaviorObservable.ts @@ -1,5 +1,5 @@ import { Observable } from "rxjs" export interface BehaviorObservable extends Observable { - getValue: () => T + getValue: () => any } diff --git a/src/internal/react-enhancer.ts b/src/internal/react-enhancer.ts index d1eafe63..2aecbb77 100644 --- a/src/internal/react-enhancer.ts +++ b/src/internal/react-enhancer.ts @@ -1,25 +1,19 @@ -import { Observable, of, Subscription, Subject, race } from "rxjs" -import { delay, takeUntil, take, filter, tap } from "rxjs/operators" +import { Observable, of, Subscription } from "rxjs" +import { delay, take, filter, tap } from "rxjs/operators" import { SUSPENSE } from "../SUSPENSE" import { BehaviorObservable } from "./BehaviorObservable" import { EMPTY_VALUE } from "./empty-value" import { noop } from "./noop" import { COMPLETE } from "./COMPLETE" -const IS_SSR = - typeof window === "undefined" || - typeof window.document === "undefined" || - typeof window.document.createElement === "undefined" - const reactEnhancer = ( source$: Observable, delayTime: number, ): BehaviorObservable => { let finalizeLastUnsubscription = noop - const onSubscribe = new Subject() + let latestValue = EMPTY_VALUE const result = new Observable(subscriber => { let isActive = true - let latestValue = EMPTY_VALUE const subscription = source$.subscribe({ next(value) { if ( @@ -34,7 +28,6 @@ const reactEnhancer = ( subscriber.error(e) }, }) - onSubscribe.next() finalizeLastUnsubscription() return () => { finalizeLastUnsubscription() @@ -57,34 +50,67 @@ const reactEnhancer = ( }) as BehaviorObservable let promise: any + let error = EMPTY_VALUE + let valueResult: { type: "v"; payload: any } | undefined const getValue = () => { + if (error !== EMPTY_VALUE) { + throw error + } + try { - return (source$ as BehaviorObservable).getValue() + const latest = (source$ as BehaviorObservable).getValue() + return valueResult && Object.is(valueResult.payload, latest) + ? valueResult + : (valueResult = { type: "v", payload: latest }) } catch (e) { - if (promise) throw promise + if (promise) return promise + + let value = EMPTY_VALUE + let isSyncError = false + promise = { + type: "s", + payload: reactEnhancer(source$, delayTime) + .pipe( + filter(value => value !== (SUSPENSE as any)), + take(1), + tap({ + next(v) { + value = v + }, + error(e) { + error = e + setTimeout(() => { + error = EMPTY_VALUE + }, 200) + }, + }), + ) + .toPromise() + .catch(e => { + if (isSyncError) return + throw e + }) + .finally(() => { + promise = undefined + valueResult = undefined + }), + } + + if (value !== EMPTY_VALUE) { + latestValue = value + return (valueResult = { type: "v", payload: value }) + } - if (!IS_SSR && e !== SUSPENSE) { - source$ - .pipe(takeUntil(race(onSubscribe, of(true).pipe(delay(60000))))) - .subscribe() - try { - return (source$ as BehaviorObservable).getValue() - } catch (e) {} + if (error !== EMPTY_VALUE) { + isSyncError = true + throw error } + + return promise } - promise = source$ - .pipe( - filter(value => value !== (SUSPENSE as any)), - take(1), - tap(() => { - promise = undefined - }), - ) - .toPromise() - throw promise } - result.getValue = getValue as () => T + result.getValue = getValue as () => T | Promise return result } diff --git a/src/internal/share-latest.ts b/src/internal/share-latest.ts index 3536f1f2..bf79e0bd 100644 --- a/src/internal/share-latest.ts +++ b/src/internal/share-latest.ts @@ -51,8 +51,12 @@ const shareLatest = ( innerSub.unsubscribe() if (refCount === 0) { currentValue = EMPTY_VALUE + if (subject !== undefined) { + teardown() + } else { + setTimeout(teardown, 200) + } subject = undefined - teardown() if (subscription) { subscription.unsubscribe() subscription = undefined diff --git a/src/internal/useObservable.ts b/src/internal/useObservable.ts index 68eb3dd0..0cf25968 100644 --- a/src/internal/useObservable.ts +++ b/src/internal/useObservable.ts @@ -2,23 +2,23 @@ import { useEffect, useReducer } from "react" import { BehaviorObservable } from "./BehaviorObservable" import { SUSPENSE } from "../SUSPENSE" +const ERROR: "e" = "e" +const VALUE: "v" = "v" +const SUSP: "s" = "s" +type Action = "e" | "v" | "s" + const reducer = ( - _: any, - { type, payload }: { type: "next" | "error"; payload: any }, + _: { type: Action; payload: any }, + action: { type: Action; payload: any }, ) => { - if (type === "error") { - throw payload - } - return payload -} -const init = (source$: BehaviorObservable) => { - try { - return source$.getValue() - } catch (e) { - return SUSPENSE + if (action.type === ERROR) { + throw action.payload } + return action } +const init = (source$: BehaviorObservable) => source$.getValue() + export const useObservable = ( source$: BehaviorObservable, ): Exclude => { @@ -26,30 +26,32 @@ export const useObservable = ( useEffect(() => { try { - dispatch({ - type: "next", - payload: source$.getValue(), - }) + dispatch(source$.getValue()) } catch (e) { - dispatch({ - type: "next", - payload: SUSPENSE, - }) + return dispatch({ type: ERROR, payload: e }) } const subscription = source$.subscribe( - value => - dispatch({ - type: "next", - payload: value, - }), + value => { + if ((value as any) === SUSPENSE) { + dispatch(source$.getValue()) + } else { + dispatch({ + type: VALUE, + payload: value, + }) + } + }, error => dispatch({ - type: "error", + type: ERROR, payload: error, }), ) return () => subscription.unsubscribe() }, [source$]) - return state !== (SUSPENSE as any) ? (state as any) : source$.getValue() + if (state.type === SUSP) { + throw state.payload + } + return state.payload } diff --git a/test/connectFactoryObservable.test.tsx b/test/connectFactoryObservable.test.tsx index e1398442..0ae4fe6c 100644 --- a/test/connectFactoryObservable.test.tsx +++ b/test/connectFactoryObservable.test.tsx @@ -1,6 +1,15 @@ import { connectFactoryObservable } from "../src" import { TestErrorBoundary } from "../test/TestErrorBoundary" -import { from, of, defer, concat, BehaviorSubject, throwError } from "rxjs" +import { + from, + of, + defer, + concat, + BehaviorSubject, + throwError, + Observable, + Subject, +} from "rxjs" import { renderHook, act as actHook } from "@testing-library/react-hooks" import { switchMap, delay } from "rxjs/operators" import { FC, Suspense, useState } from "react" @@ -223,6 +232,119 @@ describe("connectFactoryObservable", () => { ) }) + it("allows sync errors to be caught in error boundaries with suspense", () => { + const errStream = new Observable(observer => + observer.error("controlled error"), + ) + const [useError] = connectFactoryObservable((_: string) => errStream) + + const ErrorComponent = () => { + const value = useError("foo") + + return <>{value} + } + + const errorCallback = jest.fn() + const { unmount } = render( + + Loading...}> + + + , + ) + + expect(errorCallback).toHaveBeenCalledWith( + "controlled error", + expect.any(Object), + ) + unmount() + }) + + it("allows async errors to be caught in error boundaries with suspense", async () => { + const errStream = new Subject() + const [useError] = connectFactoryObservable((_: string) => errStream) + + const ErrorComponent = () => { + const value = useError("foo") + + return <>{value} + } + + const errorCallback = jest.fn() + const { unmount } = render( + + Loading...}> + + + , + ) + + await componentAct(async () => { + errStream.error("controlled error") + await wait(0) + }) + + expect(errorCallback).toHaveBeenCalledWith( + "controlled error", + expect.any(Object), + ) + unmount() + }) + + it( + "the errror-boundary can capture errors that are produced when changing the " + + "key of the hook to an observable that throws synchronously", + async () => { + const normal$ = new Subject() + const errored$ = new Observable(observer => { + observer.error("controlled error") + }) + + const [useOkKo] = connectFactoryObservable((ok: boolean) => + ok ? normal$ : errored$, + ) + + const ErrorComponent = () => { + const [ok, setOk] = useState(true) + const value = useOkKo(ok) + + return setOk(false)}>{value} + } + + const errorCallback = jest.fn() + const { unmount } = render( + + Loading...}> + + + , + ) + + expect(screen.queryByText("ALL GOOD")).toBeNull() + expect(screen.queryByText("Loading...")).not.toBeNull() + + await componentAct(async () => { + normal$.next("ALL GOOD") + await wait(50) + }) + + expect(screen.queryByText("ALL GOOD")).not.toBeNull() + expect(screen.queryByText("Loading...")).toBeNull() + expect(errorCallback).not.toHaveBeenCalled() + + componentAct(() => { + fireEvent.click(screen.getByText(/GOOD/i)) + }) + + expect(errorCallback).toHaveBeenCalledWith( + "controlled error", + expect.any(Object), + ) + + unmount() + }, + ) + it("doesn't throw errors on components that will get unmounted on the next cycle", () => { const valueStream = new BehaviorSubject(1) const [useValue, value$] = connectFactoryObservable(() => valueStream) @@ -258,6 +380,7 @@ describe("connectFactoryObservable", () => { expect(errorCallback).not.toHaveBeenCalled() }) }) + describe("observable", () => { it("it completes when the source observable completes, regardless of mounted componentes being subscribed to the source", async () => { let diff = -1 diff --git a/test/connectObservable.test.tsx b/test/connectObservable.test.tsx index e2b85461..421956bd 100644 --- a/test/connectObservable.test.tsx +++ b/test/connectObservable.test.tsx @@ -6,7 +6,15 @@ import { } from "@testing-library/react" import { act, renderHook } from "@testing-library/react-hooks" import React, { Suspense, useEffect, FC } from "react" -import { BehaviorSubject, defer, from, of, Subject, throwError } from "rxjs" +import { + BehaviorSubject, + defer, + from, + of, + Subject, + throwError, + Observable, +} from "rxjs" import { delay, scan, startWith, map, switchMap } from "rxjs/operators" import { connectObservable, SUSPENSE } from "../src" import { TestErrorBoundary } from "../test/TestErrorBoundary" @@ -245,7 +253,6 @@ describe("connectObservable", () => { const ErrorComponent = () => { const value = useError() - return <>{value} } @@ -266,18 +273,78 @@ describe("connectObservable", () => { ) }) - it("allows errors to be caught in error boundaries with suspense", () => { + it("allows sync errors to be caught in error boundaries with suspense", () => { + const errStream = new Observable(observer => + observer.error("controlled error"), + ) + const [useError] = connectObservable(errStream) + + const ErrorComponent = () => { + const value = useError() + return <>{value} + } + + const errorCallback = jest.fn() + const { unmount } = render( + + Loading...}> + + + , + ) + + expect(errorCallback).toHaveBeenCalledWith( + "controlled error", + expect.any(Object), + ) + unmount() + }) + + it("allows async errors to be caught in error boundaries with suspense", async () => { const errStream = new Subject() const [useError] = connectObservable(errStream) const ErrorComponent = () => { const value = useError() + return <>{value} + } + const errorCallback = jest.fn() + const { unmount } = render( + + Loading...}> + + + , + ) + + await componentAct(async () => { + errStream.error("controlled error") + await wait(0) + }) + + expect(errorCallback).toHaveBeenCalledWith( + "controlled error", + expect.any(Object), + ) + unmount() + }) + + it("allows to retry the errored observable after a grace period of time", async () => { + let errStream = new Subject() + const [useError] = connectObservable( + defer(() => { + return (errStream = new Subject()) + }), + ) + + const ErrorComponent = () => { + const value = useError() return <>{value} } const errorCallback = jest.fn() - render( + const { unmount } = render( Loading...}> @@ -285,14 +352,46 @@ describe("connectObservable", () => { , ) - componentAct(() => { + expect(screen.queryByText("Loading...")).not.toBeNull() + expect(screen.queryByText("ALL GOOD")).toBeNull() + + await componentAct(async () => { errStream.error("controlled error") + await wait(0) }) + expect(screen.queryByText("Loading...")).toBeNull() + expect(screen.queryByText("ALL GOOD")).toBeNull() expect(errorCallback).toHaveBeenCalledWith( "controlled error", expect.any(Object), ) + unmount() + + errorCallback.mockReset() + await componentAct(async () => { + await wait(250) + }) + + render( + + Loading...}> + + + , + ) + expect(screen.queryByText("Loading...")).not.toBeNull() + + await componentAct(async () => { + errStream.next("ALL GOOD") + await wait(50) + }) + + expect(errorCallback).not.toHaveBeenCalledWith( + "controlled error", + expect.any(Object), + ) + expect(screen.queryByText("ALL GOOD")).not.toBeNull() }) it("doesn't throw errors on components that will get unmounted on the next cycle", () => {