Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(error-handling): fix error-handling issues with suspense #54

Merged
merged 2 commits into from
Jul 9, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/internal/BehaviorObservable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Observable } from "rxjs"

export interface BehaviorObservable<T> extends Observable<T> {
getValue: () => T
getValue: () => any
}
86 changes: 56 additions & 30 deletions src/internal/react-enhancer.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(
source$: Observable<T>,
delayTime: number,
): BehaviorObservable<T> => {
let finalizeLastUnsubscription = noop
const onSubscribe = new Subject()
let latestValue = EMPTY_VALUE
const result = new Observable<T>(subscriber => {
let isActive = true
let latestValue = EMPTY_VALUE
const subscription = source$.subscribe({
next(value) {
if (
Expand All @@ -34,7 +28,6 @@ const reactEnhancer = <T>(
subscriber.error(e)
},
})
onSubscribe.next()
finalizeLastUnsubscription()
return () => {
finalizeLastUnsubscription()
Expand All @@ -57,34 +50,67 @@ const reactEnhancer = <T>(
}) as BehaviorObservable<T>

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<T>).getValue()
const latest = (source$ as BehaviorObservable<T>).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<T>).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<T>
return result
}

Expand Down
6 changes: 5 additions & 1 deletion src/internal/share-latest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,12 @@ const shareLatest = <T>(
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
Expand Down
56 changes: 29 additions & 27 deletions src/internal/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,56 @@ 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<any>) => {
try {
return source$.getValue()
} catch (e) {
return SUSPENSE
if (action.type === ERROR) {
throw action.payload
}
return action
}

const init = (source$: BehaviorObservable<any>) => source$.getValue()

export const useObservable = <O>(
source$: BehaviorObservable<O>,
): Exclude<O, typeof SUSPENSE> => {
const [state, dispatch] = useReducer(reducer, source$, init)

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
}
125 changes: 124 additions & 1 deletion test/connectFactoryObservable.test.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
<TestErrorBoundary onError={errorCallback}>
<Suspense fallback={<div>Loading...</div>}>
<ErrorComponent />
</Suspense>
</TestErrorBoundary>,
)

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(
<TestErrorBoundary onError={errorCallback}>
<Suspense fallback={<div>Loading...</div>}>
<ErrorComponent />
</Suspense>
</TestErrorBoundary>,
)

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<string>()
const errored$ = new Observable<string>(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 <span onClick={() => setOk(false)}>{value}</span>
}

const errorCallback = jest.fn()
const { unmount } = render(
<TestErrorBoundary onError={errorCallback}>
<Suspense fallback={<div>Loading...</div>}>
<ErrorComponent />
</Suspense>
</TestErrorBoundary>,
)

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)
Expand Down Expand Up @@ -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
Expand Down
Loading