Skip to content

Commit ab3877a

Browse files
committed
fix: fixed useobserve
1 parent b073c61 commit ab3877a

9 files changed

Lines changed: 228 additions & 177 deletions

File tree

Lines changed: 42 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,53 @@
11
import {
2-
catchError,
2+
BehaviorSubject,
33
distinctUntilChanged,
4-
EMPTY,
5-
map,
64
NEVER,
75
type Observable,
6+
type Subscription,
87
share,
9-
shareReplay,
108
tap,
119
} from "rxjs"
12-
import { filterObjectByKey } from "../../utils/filterObjectByKey"
13-
import { makeObservable } from "../../utils/makeObservable"
1410

15-
export class ObservableStore<T> {
16-
state: {
17-
data: T | undefined
18-
status: "pending" | "success" | "error"
19-
observableState: "complete" | "error" | "live"
20-
error: Error | undefined
21-
} = {
22-
data: undefined,
23-
status: "pending",
24-
observableState: "live",
25-
error: undefined,
26-
}
11+
export type State<T, DefaultValue, Error = unknown> = {
12+
data: T | DefaultValue
13+
status: "pending" | "success" | "error"
14+
observableState: "complete" | "error" | "live"
15+
error: Error | undefined
16+
}
17+
18+
export interface ObservableStoreOptions<T, DefaultValue> {
19+
defaultValue: DefaultValue
20+
compareFn: ((a: T, b: T) => boolean) | undefined
21+
}
2722

23+
export class ObservableStore<T, DefaultValue, Error = unknown> {
24+
state: State<T, DefaultValue, Error>
2825
source$: Observable<T | undefined>
26+
sub: Subscription
2927

3028
constructor({
3129
source$: miscSource$,
3230
defaultValue,
33-
selectorKeys,
3431
compareFn,
3532
}: {
3633
source$: Observable<T> | (() => Observable<T> | undefined)
37-
defaultValue: T | undefined
38-
selectorKeys: (keyof T)[] | undefined
39-
compareFn: ((a: T, b: T) => boolean) | undefined
40-
}) {
41-
this.state.data = defaultValue
42-
34+
} & ObservableStoreOptions<T, DefaultValue>) {
4335
const source$ =
4436
typeof miscSource$ === "function" ? miscSource$() : miscSource$
4537

46-
console.log("source$", source$)
38+
const hasNoDefinedSource = source$ === undefined
4739

48-
if (source$ === undefined) {
49-
this.state = {
50-
...this.state,
51-
status: "success",
52-
observableState: "complete",
53-
}
40+
this.state = {
41+
data: source$ instanceof BehaviorSubject ? source$.value : defaultValue,
42+
status: hasNoDefinedSource ? "success" : "pending",
43+
observableState: hasNoDefinedSource ? "complete" : "live",
44+
error: undefined,
5445
}
5546

5647
this.source$ = (source$ ?? NEVER).pipe(
57-
// Maybe selector
58-
map((v) => {
59-
if (selectorKeys && typeof v === "object" && v !== null) {
60-
return filterObjectByKey(v, selectorKeys) as T | undefined
61-
}
62-
63-
return v
64-
}),
65-
// Maybe compareFn
66-
distinctUntilChanged((a, b) => {
67-
if (a === undefined || b === undefined) return false
68-
69-
if (compareFn) {
70-
return compareFn(a as T, b as T)
71-
}
72-
73-
return a === b
74-
}),
48+
distinctUntilChanged(compareFn),
7549
tap({
7650
complete: () => {
77-
console.log("complete")
78-
7951
this.state = {
8052
...this.state,
8153
status: "success",
@@ -91,19 +63,31 @@ export class ObservableStore<T> {
9163
}
9264
},
9365
next: (data) => {
94-
console.log("next", data)
95-
9666
this.state = { ...this.state, data }
9767
},
9868
}),
99-
shareReplay({ refCount: true, bufferSize: 1 }),
69+
share(),
10070
)
10171

102-
// to clean up
103-
this.source$.subscribe()
72+
/**
73+
* @important This eager subscription will optimistically update the state.
74+
* Any observable that is non async (behavior, of(x), etc) will have in fact their state completed
75+
* by the first render cycle.
76+
* Although we only correctly type sync state for `BehaviorSubject` we can in fact get the value in sync
77+
* for more than them.
78+
* Technically the whole pipe chain runs "synchronously".
79+
*
80+
* This is not a guarantee, just that in best case scenario there will be only one render.
81+
*/
82+
this.sub = this.source$.subscribe()
10483
}
10584

10685
subscribe = (next: () => void) => {
86+
// in some case the observable is already complete by the time we subscribe to it.
87+
if (this.state.observableState === "complete") {
88+
return () => {}
89+
}
90+
10791
const sub = this.source$.subscribe({
10892
complete: next,
10993
error: next,
@@ -115,11 +99,7 @@ export class ObservableStore<T> {
11599
}
116100
}
117101

118-
getSnapshot = (): typeof this.state => {
119-
// console.log("getSnapshot", this.state)
120-
102+
getSnapshot = () => {
121103
return this.state
122104
}
123105
}
124-
125-
export const storeMap = new Map<string, ObservableStore<unknown>>()
Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
export interface UseObserveResult<Value, DefaultValue, Error = unknown> {
2-
data: Value | DefaultValue
3-
error: Error | undefined
4-
/**
5-
* - pending: if there's no cached data and no query attempt was finished yet.
6-
* - success: if the query has received a response with no errors and is ready to display its data
7-
* - error: if the query attempt resulted in an error. The corresponding error property has the error received from the attempted fetch
8-
*/
9-
status: "pending" | "success" | "error"
10-
observableState: "complete" | "live"
11-
}
1+
import type { ObservableStoreOptions, State } from "./store"
2+
3+
export interface UseObserveResult<Value, DefaultValue, Error = unknown>
4+
extends State<Value, DefaultValue, Error> {}
5+
6+
export interface UseObserveOptions<T, DefaultValue>
7+
extends Partial<ObservableStoreOptions<T, DefaultValue>> {}

src/lib/binding/useObserve/useObserve.compare.test.tsx

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
import { renderHook } from "@testing-library/react"
2-
import { act, useEffect } from "react"
2+
import { act, useEffect, useState } from "react"
33
import { BehaviorSubject } from "rxjs"
44
import { describe, expect, it } from "vitest"
55
import { isShallowEqual } from "../../utils/shallowEqual"
6+
import type { UseObserveResult } from "./types"
67
import { useObserve } from "./useObserve"
78

89
it("should not re-render when the value is same reference", async () => {
9-
const values: Array<undefined> = []
10+
const values: Array<UseObserveResult<undefined, undefined>> = []
1011
const source$ = new BehaviorSubject(undefined)
1112

1213
renderHook(() => {
1314
const value = useObserve(source$)
1415

1516
useEffect(() => {
16-
values.push(value.data)
17+
values.push(value)
1718
}, [value])
1819
}, {})
1920

20-
expect(values[0]).toEqual(undefined)
21+
expect(values[0]).toEqual({
22+
data: undefined,
23+
error: undefined,
24+
status: "pending",
25+
observableState: "live",
26+
})
2127

2228
act(() => {
2329
source$.next(undefined)
@@ -118,3 +124,61 @@ describe("given a custom compareFn", () => {
118124
expect(values.length).toBe(1)
119125
})
120126
})
127+
128+
describe("Given a forever true compareFn", () => {
129+
describe("and a new value is pushed", () => {
130+
it("should not return the new value", async () => {
131+
const values: Array<UseObserveResult<number, number>> = []
132+
const source$ = new BehaviorSubject(1)
133+
134+
renderHook(() => {
135+
const value = useObserve(source$, { compareFn: () => true })
136+
137+
useEffect(() => {
138+
values.push(value)
139+
}, [value])
140+
}, {})
141+
142+
act(() => {
143+
source$.next(2)
144+
})
145+
146+
expect(values.length).toBe(1)
147+
expect(values.at(-1)?.data).toBe(1)
148+
})
149+
})
150+
151+
describe("when a new compareFn is provided with forever false", () => {
152+
describe("and a new value is pushed", () => {
153+
it("should return the new value", async () => {
154+
const values: Array<UseObserveResult<number, number>> = []
155+
const source$ = new BehaviorSubject(1)
156+
157+
const { result } = renderHook(() => {
158+
const [compareFn, setCompareFn] = useState(() => () => true)
159+
const value = useObserve(source$, { compareFn })
160+
161+
useEffect(() => {
162+
values.push(value)
163+
}, [value])
164+
165+
return { setCompareFn }
166+
}, {})
167+
168+
expect(values.length).toBe(1)
169+
expect(values.at(-1)?.data).toBe(1)
170+
171+
act(() => {
172+
result.current.setCompareFn(() => () => false)
173+
})
174+
175+
act(() => {
176+
source$.next(2)
177+
})
178+
179+
expect(values.length).toBe(2)
180+
expect(values.at(-1)?.data).toBe(2)
181+
})
182+
})
183+
})
184+
})

src/lib/binding/useObserve/useObserve.selector.test.tsx

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { renderHook } from "@testing-library/react"
22
import { act, useEffect } from "react"
3-
import { BehaviorSubject, of } from "rxjs"
3+
import { BehaviorSubject, distinctUntilChanged, map, of } from "rxjs"
44
import { expect, it } from "vitest"
5+
import { filterObjectByKey } from "../../utils/filterObjectByKey"
6+
import { isShallowEqual } from "../../utils/shallowEqual"
57
import type { UseObserveResult } from "./types"
68
import { useObserve } from "./useObserve"
79

810
it("should return the selected keys", async () => {
911
const values: Array<{ foo: string } | undefined> = []
10-
const source$ = of({ foo: "foo" })
12+
const source$ = of({ foo: "foo" }).pipe(
13+
map((data) => filterObjectByKey(data, ["foo"])),
14+
distinctUntilChanged(isShallowEqual),
15+
)
1116

1217
renderHook(() => {
13-
const value = useObserve(source$, ["foo"])
18+
const value = useObserve(source$)
1419

1520
values.push(value.data)
1621
}, {})
@@ -19,12 +24,20 @@ it("should return the selected keys", async () => {
1924
expect(values.length).toBe(1)
2025
})
2126

22-
it("should not re-render when the selected keys don't change foobar", async () => {
27+
it("should not re-render when the selected keys don't change", async () => {
2328
const values: Array<UseObserveResult<{ a: string }, { a: string }>> = []
2429
const source$ = new BehaviorSubject({ a: "a", b: "b" })
2530

2631
renderHook(() => {
27-
const value = useObserve(source$, ["a"])
32+
const value = useObserve(
33+
() =>
34+
source$.pipe(
35+
map((data) => filterObjectByKey(data, ["a"])),
36+
distinctUntilChanged(isShallowEqual),
37+
),
38+
{ defaultValue: source$.value },
39+
[],
40+
)
2841

2942
useEffect(() => {
3043
values.push(value)
@@ -42,13 +55,6 @@ it("should not re-render when the selected keys don't change foobar", async () =
4255
source$.next({ a: "a", b: "c" })
4356
})
4457

45-
expect(values[1]).toEqual({
46-
data: { a: "a" },
47-
error: undefined,
48-
status: "pending",
49-
observableState: "live",
50-
})
51-
5258
expect(values.length).toBe(1)
5359
})
5460

@@ -57,7 +63,15 @@ it("should re-render when the selected keys change", async () => {
5763
const source$ = new BehaviorSubject({ a: "a", b: "b" })
5864

5965
renderHook(() => {
60-
const value = useObserve(source$, ["a"])
66+
const value = useObserve(
67+
() =>
68+
source$.pipe(
69+
map((data) => filterObjectByKey(data, ["a"])),
70+
distinctUntilChanged(isShallowEqual),
71+
),
72+
{ defaultValue: source$.value },
73+
[],
74+
)
6175

6276
useEffect(() => {
6377
values.push(value.data)

src/lib/binding/useObserve/useObserve.test.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BehaviorSubject, map, type Observable, of, Subject, timer } from "rxjs"
44
import { afterEach, describe, expect, expectTypeOf, it } from "vitest"
55
import { waitForTimeout } from "../../../tests/utils"
66
import { useConstant } from "../../utils/react/useConstant"
7+
import type { UseObserveResult } from "./types"
78
import { useObserve } from "./useObserve"
89

910
afterEach(() => {
@@ -13,14 +14,17 @@ afterEach(() => {
1314
describe("useObserve", () => {
1415
describe("Given a non BehaviorSubject observable", () => {
1516
it("should return `foo` as first render cycle due to internal optimization and should only have one render cycle", async () => {
16-
const values: Array<string | undefined> = []
17+
const values: Array<UseObserveResult<string, undefined>> = []
1718
const source$ = of("foo")
1819

1920
renderHook(() => {
20-
values.push(useObserve(source$).data)
21+
values.push(useObserve(source$))
2122
}, {})
2223

23-
expect(values[0]).toBe("foo")
24+
expect(values[0]?.data).toBe("foo")
25+
26+
console.log("values", values)
27+
2428
expect(values.length).toBe(1)
2529
})
2630
})
@@ -275,7 +279,6 @@ describe("useObserve", () => {
275279
undefined,
276280
)
277281

278-
// console.log("RENDER")
279282
values.push(useObserve(() => res, [res]))
280283

281284
useEffect(() => {

0 commit comments

Comments
 (0)