Skip to content
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
19 changes: 16 additions & 3 deletions src/lib/binding/useObserve/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
distinctUntilChanged,
NEVER,
type Observable,
type Subscription,
Subscription,
share,
tap,
} from "rxjs"
Expand All @@ -30,7 +30,7 @@ export class ObservableStore<T, DefaultValue, Error = unknown> {
defaultValue,
compareFn,
}: {
source$: Observable<T> | (() => Observable<T> | undefined)
source$: Observable<T> | undefined | (() => Observable<T> | undefined)
} & ObservableStoreOptions<T, DefaultValue>) {
const source$ =
typeof miscSource$ === "function" ? miscSource$() : miscSource$
Expand All @@ -44,7 +44,20 @@ export class ObservableStore<T, DefaultValue, Error = unknown> {
error: undefined,
}

this.source$ = (source$ ?? NEVER).pipe(
/**
* There is nothing to observe without a source. The state above is already
* final (`complete`), which makes `subscribe` a no-op and therefore makes
* `source$` unreachable. We skip building the pipe chain and opening a
* subscription for a stream that can never emit.
*/
if (hasNoDefinedSource) {
this.source$ = NEVER
this.sub = Subscription.EMPTY

return
}

this.source$ = source$.pipe(
distinctUntilChanged(compareFn),
tap({
complete: () => {
Expand Down
121 changes: 121 additions & 0 deletions src/lib/binding/useObserve/useObserve.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,125 @@ describe("useObserve", () => {
])
})
})

describe("Given a source that may or may not be an observable", () => {
it("should type return correctly", async () => {
renderHook(() => {
const value = useObserve(of(1) as Observable<number> | undefined)

expectTypeOf(value.data).toEqualTypeOf<number | undefined>()

const withDefaultValue = useObserve(
of(1) as Observable<number> | undefined,
{ defaultValue: null },
)

expectTypeOf(withDefaultValue.data).toEqualTypeOf<number | null>()

const subject = useObserve(
new BehaviorSubject(1) as BehaviorSubject<number> | undefined,
)

expectTypeOf(subject.data).toEqualTypeOf<number | undefined>()
}, {})

expect(true).toBe(true)
})
})

describe("Given an undefined source", () => {
it("should type return correctly", async () => {
renderHook(() => {
const value = useObserve(undefined)

expectTypeOf(value.data).toEqualTypeOf<undefined>()

const withDefaultValue = useObserve(undefined, { defaultValue: null })

expectTypeOf(withDefaultValue.data).toEqualTypeOf<null>()
}, {})

expect(true).toBe(true)
})

it("should return the default value", async () => {
const { result } = renderHook(() => useObserve(undefined), {})

expect(result.current).toEqual({
data: undefined,
status: "success",
observableState: "complete",
error: undefined,
})
})

it("should not subscribe to anything nor trigger an extra render", async () => {
let numberOfRenders = 0

renderHook(() => {
numberOfRenders++

useObserve(undefined)
}, {})

await act(async () => {
await waitForTimeout(10)
})

expect(numberOfRenders).toBe(1)
})

it("should return custom default value", async () => {
const { result } = renderHook(
() => useObserve(undefined, { defaultValue: null }),
{},
)

expect(result.current).toEqual({
data: null,
status: "success",
observableState: "complete",
error: undefined,
})
})

it("should return undefined and then the correct value once the source is defined", async () => {
// biome-ignore lint/suspicious/noExplicitAny: TODO
const values: any = []

renderHook(() => {
const [source$, setSource] = useState<
BehaviorSubject<number> | undefined
>(undefined)

values.push(useObserve(source$))

useEffect(() => {
setTimeout(() => {
setSource(new BehaviorSubject(1))
}, 1)
}, [])
}, {})

await act(async () => {
await waitForTimeout(10)
})

expect(values).toEqual([
{
data: undefined,
status: "success",
observableState: "complete",
error: undefined,
},
// still live because not completed
{
data: 1,
status: "pending",
observableState: "live",
error: undefined,
},
])
})
})
})
35 changes: 31 additions & 4 deletions src/lib/binding/useObserve/useObserve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ interface Option<T, R = undefined> {
compareFn?: (a: T, b: T) => boolean
}

/**
* The source can be `undefined` (directly or returned from a factory). This is
* useful when the observable is not available yet (lazily created, coming from
* a state, a prop, etc). In that case the hook returns the default value with a
* `complete` observable state and will start observing as soon as an actual
* source is given.
*/
export function useObserve(
source: undefined,
): UseObserveResult<never, undefined>

export function useObserve<DefaultValue>(
source: undefined,
options: Option<unknown, DefaultValue>,
): UseObserveResult<never, DefaultValue>

export function useObserve<T>(
source: BehaviorSubject<T>,
): UseObserveResult<T, T>
Expand All @@ -20,7 +36,7 @@ export function useObserve<T>(
): UseObserveResult<T, T>

export function useObserve<T>(
source: Observable<T>,
source: Observable<T> | undefined,
): UseObserveResult<T, undefined>

export function useObserve<T>(
Expand All @@ -34,19 +50,30 @@ export function useObserve<T>(
): UseObserveResult<T, undefined>

export function useObserve<T, DefaultValue>(
source: Observable<T>,
source: Observable<T> | undefined,
options: Option<T, DefaultValue>,
): UseObserveResult<T, DefaultValue>

export function useObserve<T>(
source: Observable<T> | undefined,
options: Omit<Option<T>, "defaultValue">,
): UseObserveResult<T, undefined>

export function useObserve<T, DefaultValue>(
source: () => Observable<T>,
options: Option<T, DefaultValue>,
deps: DependencyList,
): UseObserveResult<T, DefaultValue>

export function useObserve<T, DefaultValue>(
source: () => Observable<T> | undefined,
options: Option<T, DefaultValue>,
deps: DependencyList,
): UseObserveResult<T, DefaultValue>

export function useObserve<T, DefaultValue = T>(
source$: Observable<T> | (() => Observable<T> | undefined),
optionsOrDeps?: Partial<Option<DefaultValue>> | DependencyList,
source$: Observable<T> | undefined | (() => Observable<T> | undefined),
optionsOrDeps?: Partial<Option<T, DefaultValue>> | DependencyList,
maybeDeps?: DependencyList,
): UseObserveResult<T, DefaultValue | undefined> {
const options =
Expand Down
2 changes: 1 addition & 1 deletion src/lib/binding/useObserve/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type StoreReference<T, DefaultValue> = {
* However this is "okay" since it is to be used inside a useSyncExternalStore and not directly in a render.
*/
export const useStore = <T, DefaultValue>(
source$: Observable<T> | (() => Observable<T> | undefined),
source$: Observable<T> | undefined | (() => Observable<T> | undefined),
options: UseObserveOptions<T, DefaultValue>,
deps: DependencyList,
): ObservableStore<T, DefaultValue | undefined> => {
Expand Down