Skip to content

Commit

Permalink
fix(error-handling): fix error-handling issues with suspense
Browse files Browse the repository at this point in the history
  • Loading branch information
josepot committed Jul 9, 2020
1 parent 08cd48e commit 92ac139
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 65 deletions.
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

0 comments on commit 92ac139

Please sign in to comment.