Skip to content

Commit 3e78524

Browse files
committed
feat: added selector
1 parent 52004f2 commit 3e78524

3 files changed

Lines changed: 247 additions & 27 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { renderHook } from "@testing-library/react"
2+
import { act, useEffect } from "react"
3+
import { BehaviorSubject } from "rxjs"
4+
import { describe, expect, it } from "vitest"
5+
import { isShallowEqual } from "../utils/shallowEqual"
6+
import { useObserve } from "./useObserve"
7+
8+
it("should not re-render when the value is same reference", async () => {
9+
const values: Array<undefined> = []
10+
const source$ = new BehaviorSubject(undefined)
11+
12+
renderHook(() => {
13+
const value = useObserve(source$)
14+
15+
useEffect(() => {
16+
values.push(value)
17+
}, [value])
18+
}, {})
19+
20+
expect(values[0]).toEqual(undefined)
21+
22+
act(() => {
23+
source$.next(undefined)
24+
})
25+
26+
expect(values.length).toBe(1)
27+
})
28+
29+
it("should re-render when the object is same but new reference", async () => {
30+
const values: Array<{ a: string } | undefined> = []
31+
const source$ = new BehaviorSubject({ a: "a" })
32+
33+
renderHook(() => {
34+
const value = useObserve(source$)
35+
36+
useEffect(() => {
37+
values.push(value)
38+
}, [value])
39+
}, {})
40+
41+
expect(values[0]).toEqual({ a: "a" })
42+
43+
act(() => {
44+
source$.next({ a: "a" })
45+
})
46+
47+
expect(values[1]).toEqual({ a: "a" })
48+
expect(values.length).toBe(2)
49+
})
50+
51+
it("should not return new value when the hook re-render but the value is the same", async () => {
52+
const values: Array<{ a: string } | undefined> = []
53+
const source$ = new BehaviorSubject({ a: "a" })
54+
55+
const { rerender } = renderHook(() => {
56+
const value = useObserve(source$)
57+
58+
useEffect(() => {
59+
values.push(value)
60+
}, [value])
61+
}, {})
62+
63+
expect(values[0]).toEqual({ a: "a" })
64+
65+
rerender()
66+
rerender()
67+
68+
expect(values.length).toBe(1)
69+
})
70+
71+
describe("given a custom compareFn", () => {
72+
it("should re-render when the object is same but new reference", async () => {
73+
const values: Array<{ a: string } | undefined> = []
74+
const source$ = new BehaviorSubject({ a: "a" })
75+
76+
renderHook(() => {
77+
const value = useObserve(source$, {
78+
compareFn: isShallowEqual,
79+
})
80+
81+
useEffect(() => {
82+
values.push(value)
83+
}, [value])
84+
}, {})
85+
86+
expect(values[0]).toEqual({ a: "a" })
87+
88+
act(() => {
89+
source$.next({ a: "a" })
90+
})
91+
92+
expect(values.length).toBe(1)
93+
})
94+
95+
it("should not return new value when the hook re-render but the value is the same shallow equal", async () => {
96+
const values: Array<{ a: string } | undefined> = []
97+
const source$ = new BehaviorSubject({ a: "a" })
98+
99+
const { rerender } = renderHook(() => {
100+
const value = useObserve(source$, {
101+
compareFn: isShallowEqual,
102+
})
103+
104+
useEffect(() => {
105+
values.push(value)
106+
}, [value])
107+
}, {})
108+
109+
expect(values[0]).toEqual({ a: "a" })
110+
111+
act(() => {
112+
source$.next({ a: "a" })
113+
})
114+
115+
rerender()
116+
rerender()
117+
118+
expect(values.length).toBe(1)
119+
})
120+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { renderHook } from "@testing-library/react"
2+
import { act, useEffect } from "react"
3+
import { BehaviorSubject, of } from "rxjs"
4+
import { expect, it } from "vitest"
5+
import { useObserve } from "./useObserve"
6+
7+
it("should return the selected keys", async () => {
8+
const values: Array<{ foo: string } | undefined> = []
9+
const source$ = of({ foo: "foo" })
10+
11+
renderHook(() => {
12+
const value = useObserve(source$, ["foo"])
13+
14+
values.push(value)
15+
}, {})
16+
17+
expect(values[0]).toEqual({ foo: "foo" })
18+
expect(values.length).toBe(1)
19+
})
20+
21+
it("should not re-render when the selected keys don't change", async () => {
22+
const values: Array<{ a: string } | undefined> = []
23+
const source$ = new BehaviorSubject({ a: "a", b: "b" })
24+
25+
renderHook(() => {
26+
const value = useObserve(source$, ["a"])
27+
28+
useEffect(() => {
29+
values.push(value)
30+
}, [value])
31+
}, {})
32+
33+
expect(values[0]).toEqual({ a: "a" })
34+
35+
act(() => {
36+
source$.next({ a: "a", b: "c" })
37+
})
38+
39+
expect(values.length).toBe(1)
40+
})
41+
42+
it("should re-render when the selected keys change", async () => {
43+
const values: Array<{ a: string } | undefined> = []
44+
const source$ = new BehaviorSubject({ a: "a", b: "b" })
45+
46+
renderHook(() => {
47+
const value = useObserve(source$, ["a"])
48+
49+
useEffect(() => {
50+
values.push(value)
51+
}, [value])
52+
}, {})
53+
54+
expect(values[0]).toEqual({ a: "a" })
55+
56+
act(() => {
57+
source$.next({ a: "b", b: "c" })
58+
})
59+
60+
expect(values[1]).toEqual({ a: "b" })
61+
expect(values.length).toBe(2)
62+
})

src/lib/binding/useObserve.ts

Lines changed: 65 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ import {
1111
distinctUntilChanged,
1212
EMPTY,
1313
identity,
14+
map,
1415
type Observable,
1516
type Subscription,
1617
shareReplay,
1718
startWith,
1819
tap,
1920
} from "rxjs"
21+
import { filterObjectByKey } from "../utils/filterObjectByKey"
2022
import { makeObservable } from "../utils/makeObservable"
2123
import { useLiveRef } from "../utils/react/useLiveRef"
24+
import { isShallowEqual } from "../utils/shallowEqual"
2225

2326
interface Option<R = undefined> {
2427
defaultValue: R
@@ -27,8 +30,20 @@ interface Option<R = undefined> {
2730
}
2831

2932
export function useObserve<T>(source: BehaviorSubject<T>): T
33+
export function useObserve<T extends object, SelectorKeys extends keyof T>(
34+
source: BehaviorSubject<T>,
35+
selector: SelectorKeys[],
36+
): { [K in SelectorKeys]: T[K] }
37+
export function useObserve<T>(
38+
source: BehaviorSubject<T>,
39+
options: Omit<Option<T>, "defaultValue">,
40+
): T
3041

3142
export function useObserve<T>(source: Observable<T>): T | undefined
43+
export function useObserve<T extends object, SelectorKeys extends keyof T>(
44+
source: Observable<T>,
45+
selector: SelectorKeys[],
46+
): { [K in SelectorKeys]: T[K] } | undefined
3247

3348
export function useObserve<T>(
3449
source: () => Observable<T>,
@@ -48,19 +63,19 @@ export function useObserve<T>(
4863
deps: DependencyList,
4964
): T
5065

51-
export function useObserve<T>(
66+
export function useObserve<T, SelectorKeys extends keyof T>(
5267
source$: Observable<T> | (() => Observable<T> | undefined),
53-
optionsOrDeps?: Option<T> | DependencyList,
68+
optionsOrDeps?: Partial<Option<T>> | DependencyList | SelectorKeys[],
5469
maybeDeps?: DependencyList,
5570
): T {
5671
const options =
5772
optionsOrDeps != null && !Array.isArray(optionsOrDeps)
58-
? (optionsOrDeps as Option<T>)
73+
? (optionsOrDeps as Partial<Option<T>>)
5974
: ({
6075
defaultValue: undefined,
6176
unsubscribeOnUnmount: true,
6277
compareFn: undefined,
63-
} satisfies Option<undefined>)
78+
} satisfies Partial<Option<T>>)
6479
const deps =
6580
!maybeDeps && Array.isArray(optionsOrDeps)
6681
? optionsOrDeps
@@ -70,18 +85,54 @@ export function useObserve<T>(
7085
const valueRef = useRef<{ value: T | undefined } | undefined>(undefined)
7186
const sourceRef = useLiveRef(source$)
7287
const optionsRef = useLiveRef(options)
88+
const selectorKey =
89+
typeof source$ !== "function" && Array.isArray(optionsOrDeps)
90+
? JSON.stringify(optionsOrDeps)
91+
: undefined
92+
const selectorRef = useLiveRef(
93+
typeof source$ !== "function" && Array.isArray(optionsOrDeps)
94+
? (optionsOrDeps as SelectorKeys[])
95+
: undefined,
96+
)
7397

74-
// biome-ignore lint/correctness/useExhaustiveDependencies: TODO
75-
const observable = useMemo(
76-
() => ({
77-
observable: makeObservable(sourceRef.current)().pipe(
98+
const observable = useMemo(() => {
99+
void selectorKey
100+
101+
const selectorOption = selectorRef.current
102+
const compareFnOption = optionsRef.current.compareFn
103+
const compareFn = compareFnOption
104+
? compareFnOption
105+
: selectorOption
106+
? isShallowEqual
107+
: undefined
108+
const observable$ = makeObservable(sourceRef.current)()
109+
110+
return {
111+
observable: observable$.pipe(
112+
// Maybe selector
113+
map((v) => {
114+
if (selectorOption && typeof v === "object" && v !== null) {
115+
return filterObjectByKey(v, selectorOption) as T | undefined
116+
}
117+
118+
return v
119+
}),
120+
// Maybe compareFn
121+
distinctUntilChanged((a, b) => {
122+
if (a === undefined || b === undefined) return false
123+
124+
if (compareFn) {
125+
return compareFn(a as T, b as T)
126+
}
127+
128+
return a === b
129+
}),
78130
shareReplay({ refCount: true, bufferSize: 1 }),
79131
),
80132
subscribed: false,
81133
snapshotSub: undefined as Subscription | undefined,
82-
}),
83-
[...deps],
84-
)
134+
}
135+
}, [...deps, selectorKey, selectorRef, sourceRef, optionsRef])
85136

86137
const getSnapshot = useCallback(() => {
87138
/**
@@ -92,8 +143,8 @@ export function useObserve<T>(
92143
if (!observable.subscribed) {
93144
observable.subscribed = true
94145

95-
const sub = observable.observable.subscribe((v) => {
96-
valueRef.current = { value: v }
146+
const sub = observable.observable.subscribe((value) => {
147+
valueRef.current = { value: value as T }
97148
})
98149

99150
observable.snapshotSub = sub
@@ -114,21 +165,8 @@ export function useObserve<T>(
114165
optionsRef.current.defaultValue
115166
? startWith(optionsRef.current.defaultValue)
116167
: identity,
117-
/**
118-
* @important there is already a Object.is comparison in place from react
119-
* so we only add a custom compareFn if provided
120-
*/
121-
distinctUntilChanged((a, b) => {
122-
if (optionsRef.current.compareFn) {
123-
if (a === undefined || b === undefined) return false
124-
125-
return optionsRef.current.compareFn(a, b)
126-
}
127-
128-
return false
129-
}),
130168
tap((value) => {
131-
valueRef.current = { value }
169+
valueRef.current = { value: value as T }
132170
}),
133171
catchError((error) => {
134172
console.error(error)

0 commit comments

Comments
 (0)