Skip to content

feat(bind): factory observable functions should receive a key #77

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

Closed
wants to merge 1 commit into from
Closed
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ const Story: React.FC<{id: number}> = ({id}) => {
)
}
```
Accepts: A factory function that returns an Observable.
Accepts: A factory function that receives a key and returns an Observable.

Returns `[1, 2]`

1. A React Hook function with the same parameters as the factory function. This hook
1. A React Hook function that takes a key as a parameter. This hook
will yield the latest update from the observable returned from the factory function.
If the Observable doesn't synchronously emit a value upon the first subscription, then
the hook will leverage React Suspense while it's waiting for the first value.
Expand Down
4 changes: 2 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ const Story: React.FC<{id: number}> = ({id}) => {
)
}
```
Accepts: A factory function that returns an Observable.
Accepts: A factory function that receives a key and returns an Observable.

Returns `[1, 2]`

1. A React Hook function with the same parameters as the factory function. This hook
1. A React Hook function that takes a key as a parameter. This hook
will yield the latest update from the observable returned from the factory function.
If the Observable doesn't synchronously emit a value upon the first subscription, then
the hook will leverage React Suspense while it's waiting for the first value.
Expand Down
51 changes: 25 additions & 26 deletions packages/core/src/bind/connectFactoryObservable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { bind } from "../"
import { TestErrorBoundary } from "../test-helpers/TestErrorBoundary"

const wait = (ms: number) => new Promise(res => setTimeout(res, ms))
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))

describe("connectFactoryObservable", () => {
const originalError = console.error
Expand All @@ -43,7 +43,7 @@ describe("connectFactoryObservable", () => {
describe("hook", () => {
it("returns the latest emitted value", async () => {
const valueStream = new BehaviorSubject(1)
const [useNumber] = bind(() => valueStream)
const [useNumber] = bind((_: void) => valueStream)
const { result } = renderHook(() => useNumber())
expect(result.current).toBe(1)

Expand All @@ -55,7 +55,7 @@ describe("connectFactoryObservable", () => {

it("suspends the component when the observable hasn't emitted yet.", async () => {
const source$ = of(1).pipe(delay(100))
const [useDelayedNumber] = bind(() => source$)
const [useDelayedNumber] = bind((_: void) => source$)
const Result: React.FC = () => <div>Result {useDelayedNumber()}</div>
const TestSuspense: React.FC = () => {
return (
Expand Down Expand Up @@ -83,27 +83,24 @@ describe("connectFactoryObservable", () => {
return from([1, 2, 3, 4, 5])
})

const [
useLatestNumber,
latestNumber$,
] = bind((id: number, value: number) =>
concat(observable$, of(id + value)),
const [useLatestNumber, latestNumber$] = bind((id: number) =>
concat(observable$, of(id)),
)
expect(subscriberCount).toBe(0)

renderHook(() => useLatestNumber(1, 1))
renderHook(() => useLatestNumber(1))
expect(subscriberCount).toBe(1)

renderHook(() => useLatestNumber(1, 1))
renderHook(() => useLatestNumber(1))
expect(subscriberCount).toBe(1)

latestNumber$(1, 1).subscribe()
latestNumber$(1).subscribe()
expect(subscriberCount).toBe(1)

renderHook(() => useLatestNumber(1, 2))
renderHook(() => useLatestNumber(2))
expect(subscriberCount).toBe(2)

renderHook(() => useLatestNumber(2, 2))
renderHook(() => useLatestNumber(3))
expect(subscriberCount).toBe(3)
})

Expand All @@ -127,7 +124,7 @@ describe("connectFactoryObservable", () => {

it("suspends the component when the factory-observable hasn't emitted yet.", async () => {
const [useDelayedNumber] = bind((x: number) => of(x).pipe(delay(50)))
const Result: React.FC<{ input: number }> = p => (
const Result: React.FC<{ input: number }> = (p) => (
<div>Result {useDelayedNumber(p.input)}</div>
)
const TestSuspense: React.FC = () => {
Expand All @@ -137,7 +134,7 @@ describe("connectFactoryObservable", () => {
<Suspense fallback={<span>Waiting</span>}>
<Result input={input} />
</Suspense>
<button onClick={() => setInput(x => x + 1)}>increase</button>
<button onClick={() => setInput((x) => x + 1)}>increase</button>
</>
)
}
Expand Down Expand Up @@ -205,7 +202,7 @@ describe("connectFactoryObservable", () => {

it("allows errors to be caught in error boundaries", () => {
const errStream = new BehaviorSubject(1)
const [useError] = bind(() => errStream)
const [useError] = bind((_: void) => errStream)

const ErrorComponent = () => {
const value = useError()
Expand All @@ -231,7 +228,7 @@ describe("connectFactoryObservable", () => {
})

it("allows sync errors to be caught in error boundaries with suspense", () => {
const errStream = new Observable(observer =>
const errStream = new Observable((observer) =>
observer.error("controlled error"),
)
const [useError] = bind((_: string) => errStream)
Expand Down Expand Up @@ -294,7 +291,7 @@ describe("connectFactoryObservable", () => {
"key of the hook to an observable that throws synchronously",
async () => {
const normal$ = new Subject<string>()
const errored$ = new Observable<string>(observer => {
const errored$ = new Observable<string>((observer) => {
observer.error("controlled error")
})

Expand Down Expand Up @@ -343,9 +340,11 @@ describe("connectFactoryObservable", () => {

it("doesn't throw errors on components that will get unmounted on the next cycle", () => {
const valueStream = new BehaviorSubject(1)
const [useValue, value$] = bind(() => valueStream)
const [useError] = bind(() =>
value$().pipe(switchMap(v => (v === 1 ? of(v) : throwError("error")))),
const [useValue, value$] = bind((_: void) => valueStream)
const [useError] = bind((_: void) =>
value$().pipe(
switchMap((v) => (v === 1 ? of(v) : throwError("error"))),
),
)

const ErrorComponent: FC = () => {
Expand Down Expand Up @@ -382,12 +381,12 @@ describe("connectFactoryObservable", () => {
let diff = -1
const [useLatestNumber, getShared] = bind((_: number) => {
diff++
return from([1, 2, 3, 4].map(val => val + diff))
return from([1, 2, 3, 4].map((val) => val + diff))
}, 0)

let latestValue1: number = 0
let nUpdates = 0
const sub1 = getShared(0).subscribe(x => {
const sub1 = getShared(0).subscribe((x) => {
latestValue1 = x
nUpdates += 1
})
Expand All @@ -400,7 +399,7 @@ describe("connectFactoryObservable", () => {
expect(nUpdates).toBe(4)

let latestValue2: number = 0
const sub2 = getShared(0).subscribe(x => {
const sub2 = getShared(0).subscribe((x) => {
latestValue2 = x
nUpdates += 1
})
Expand All @@ -409,7 +408,7 @@ describe("connectFactoryObservable", () => {
expect(sub2.closed).toBe(true)

let latestValue3: number = 0
const sub3 = getShared(0).subscribe(x => {
const sub3 = getShared(0).subscribe((x) => {
latestValue3 = x
nUpdates += 1
})
Expand All @@ -421,7 +420,7 @@ describe("connectFactoryObservable", () => {
await wait(10)

let latestValue4: number = 0
const sub4 = getShared(0).subscribe(x => {
const sub4 = getShared(0).subscribe((x) => {
latestValue4 = x
nUpdates += 1
})
Expand Down
25 changes: 9 additions & 16 deletions packages/core/src/bind/connectFactoryObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { takeUntilComplete } from "../internal/take-until-complete"
* Accepts: A factory function that returns an Observable.
*
* Returns [1, 2]
* 1. A React Hook function with the same parameters as the factory function.
* 1. A React Hook function that takes a key as a parameter.
* This hook will yield the latest update from the observable returned from
* the factory function.
* 2. A `sharedLatest` version of the observable generated by the factory
Expand All @@ -28,29 +28,22 @@ import { takeUntilComplete } from "../internal/take-until-complete"
* subscription, then the hook will leverage React Suspense while it's waiting
* for the first value.
*/
export default function connectFactoryObservable<
A extends (number | string | boolean | null)[],
O
>(
getObservable: (...args: A) => Observable<O>,
export default function connectFactoryObservable<K, O>(
getObservable: (key: K) => Observable<O>,
unsubscribeGraceTime: number,
): [
(...args: A) => Exclude<O, typeof SUSPENSE>,
(...args: A) => Observable<O>,
] {
const cache = new Map<string, [Observable<O>, BehaviorObservable<O>]>()
): [(key: K) => Exclude<O, typeof SUSPENSE>, (key: K) => Observable<O>] {
const cache = new Map<K, [Observable<O>, BehaviorObservable<O>]>()

const getSharedObservables$ = (
...input: A
key: K,
): [Observable<O>, BehaviorObservable<O>] => {
const key = JSON.stringify(input)
const cachedVal = cache.get(key)

if (cachedVal !== undefined) {
return cachedVal
}

const sharedObservable$ = shareLatest(getObservable(...input), () => {
const sharedObservable$ = shareLatest(getObservable(key), () => {
cache.delete(key)
})

Expand All @@ -69,7 +62,7 @@ export default function connectFactoryObservable<
}

return [
(...input: A) => useObservable(getSharedObservables$(...input)[1]),
(...input: A) => getSharedObservables$(...input)[0],
(key: K) => useObservable(getSharedObservables$(key)[1]),
(key: K) => getSharedObservables$(key)[0],
]
}
16 changes: 8 additions & 8 deletions packages/core/src/bind/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ export function bind<T>(
/**
* Binds a factory observable to React
*
* @param getObservable Factory of observables. The arguments of this function
* will be the ones used in the hook.
* @param getObservable Factory of observables. The argument of this function
* will be the key used in the hook.
* @param unsubscribeGraceTime (= 200): Amount of time in ms that the shared
* observable should wait before unsubscribing from the source observable when
* there are no new subscribers.
* @returns [1, 2]
* 1. A React Hook function with the same parameters as the factory function.
* 1. A React Hook function that takes a key as a parameter.
* This hook will yield the latest update from the observable returned from
* the factory function.
* 2. A `sharedLatest` version of the observable generated by the factory
Expand All @@ -46,13 +46,13 @@ export function bind<T>(
* subscription, then the hook will leverage React Suspense while it's waiting
* for the first value.
*/
export function bind<A extends (number | string | boolean | null)[], O>(
getObservable: (...args: A) => Observable<O>,
export function bind<K, O>(
getObservable: (key: K) => Observable<O>,
unsubscribeGraceTime?: number,
): [(...args: A) => Exclude<O, typeof SUSPENSE>, (...args: A) => Observable<O>]
): [(key: K) => Exclude<O, typeof SUSPENSE>, (key: K) => Observable<O>]

export function bind<A extends (number | string | boolean | null)[], O>(
obs: ((...args: A) => Observable<O>) | Observable<O>,
export function bind<K, O>(
obs: ((key: K) => Observable<O>) | Observable<O>,
unsubscribeGraceTime = 200,
) {
return (typeof obs === "function"
Expand Down
43 changes: 24 additions & 19 deletions packages/dom/src/batchUpdates.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@ import { bind } from "@react-rxjs/core"
import { batchUpdates } from "./"
import { render, screen } from "@testing-library/react"

const wait = (ms: number) => new Promise(res => setTimeout(res, ms))

const [useLatestNumber] = bind(
(id: string, batched: boolean) =>
(id === "error"
? throwError("controlled error")
: from([1, 2, 3, 4, 5])
).pipe(
delay(5),
batched ? batchUpdates() : (x: Observable<number>) => x,
startWith(0),
),
0,
)
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms))

const parseKey = (key: string): [string, boolean] => {
return JSON.parse(key)
}

const getKey = (id: string, batched: boolean) => JSON.stringify([id, batched])

const [useLatestNumber] = bind((key: string) => {
const [id, batched] = parseKey(key)
return (id === "error"
? throwError("controlled error")
: from([1, 2, 3, 4, 5])
).pipe(
delay(5),
batched ? batchUpdates() : (x: Observable<number>) => x,
startWith(0),
)
}, 0)

class TestErrorBoundary extends Component<
{
Expand Down Expand Up @@ -55,13 +60,13 @@ interface Props {
id: string
}
const Grandson: React.FC<Props> = ({ onRender, batched, id }) => {
const latestNumber = useLatestNumber(id, batched)
const latestNumber = useLatestNumber(getKey(id, batched))
useLayoutEffect(onRender)
return <div>Grandson {latestNumber}</div>
}

const Son: React.FC<Props> = props => {
const latestNumber = useLatestNumber(props.id, props.batched)
const Son: React.FC<Props> = (props) => {
const latestNumber = useLatestNumber(getKey(props.id, props.batched))
useLayoutEffect(props.onRender)
return (
<div>
Expand All @@ -71,8 +76,8 @@ const Son: React.FC<Props> = props => {
)
}

const Father: React.FC<Props> = props => {
const latestNumber = useLatestNumber(props.id, props.batched)
const Father: React.FC<Props> = (props) => {
const latestNumber = useLatestNumber(getKey(props.id, props.batched))
useLayoutEffect(props.onRender)
return (
<div>
Expand Down