diff --git a/src/lib/binding/useObserve/store.ts b/src/lib/binding/useObserve/store.ts index 057e237..7cdd7c3 100644 --- a/src/lib/binding/useObserve/store.ts +++ b/src/lib/binding/useObserve/store.ts @@ -3,7 +3,7 @@ import { distinctUntilChanged, NEVER, type Observable, - type Subscription, + Subscription, share, tap, } from "rxjs" @@ -30,7 +30,7 @@ export class ObservableStore { defaultValue, compareFn, }: { - source$: Observable | (() => Observable | undefined) + source$: Observable | undefined | (() => Observable | undefined) } & ObservableStoreOptions) { const source$ = typeof miscSource$ === "function" ? miscSource$() : miscSource$ @@ -44,7 +44,20 @@ export class ObservableStore { 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: () => { diff --git a/src/lib/binding/useObserve/useObserve.test.tsx b/src/lib/binding/useObserve/useObserve.test.tsx index f4394a8..1eaf234 100644 --- a/src/lib/binding/useObserve/useObserve.test.tsx +++ b/src/lib/binding/useObserve/useObserve.test.tsx @@ -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 | undefined) + + expectTypeOf(value.data).toEqualTypeOf() + + const withDefaultValue = useObserve( + of(1) as Observable | undefined, + { defaultValue: null }, + ) + + expectTypeOf(withDefaultValue.data).toEqualTypeOf() + + const subject = useObserve( + new BehaviorSubject(1) as BehaviorSubject | undefined, + ) + + expectTypeOf(subject.data).toEqualTypeOf() + }, {}) + + expect(true).toBe(true) + }) + }) + + describe("Given an undefined source", () => { + it("should type return correctly", async () => { + renderHook(() => { + const value = useObserve(undefined) + + expectTypeOf(value.data).toEqualTypeOf() + + const withDefaultValue = useObserve(undefined, { defaultValue: null }) + + expectTypeOf(withDefaultValue.data).toEqualTypeOf() + }, {}) + + 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 | 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, + }, + ]) + }) + }) }) diff --git a/src/lib/binding/useObserve/useObserve.ts b/src/lib/binding/useObserve/useObserve.ts index 2a8b69f..8922c6d 100644 --- a/src/lib/binding/useObserve/useObserve.ts +++ b/src/lib/binding/useObserve/useObserve.ts @@ -10,6 +10,22 @@ interface Option { 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 + +export function useObserve( + source: undefined, + options: Option, +): UseObserveResult + export function useObserve( source: BehaviorSubject, ): UseObserveResult @@ -20,7 +36,7 @@ export function useObserve( ): UseObserveResult export function useObserve( - source: Observable, + source: Observable | undefined, ): UseObserveResult export function useObserve( @@ -34,19 +50,30 @@ export function useObserve( ): UseObserveResult export function useObserve( - source: Observable, + source: Observable | undefined, options: Option, ): UseObserveResult +export function useObserve( + source: Observable | undefined, + options: Omit, "defaultValue">, +): UseObserveResult + export function useObserve( source: () => Observable, options: Option, deps: DependencyList, ): UseObserveResult +export function useObserve( + source: () => Observable | undefined, + options: Option, + deps: DependencyList, +): UseObserveResult + export function useObserve( - source$: Observable | (() => Observable | undefined), - optionsOrDeps?: Partial> | DependencyList, + source$: Observable | undefined | (() => Observable | undefined), + optionsOrDeps?: Partial> | DependencyList, maybeDeps?: DependencyList, ): UseObserveResult { const options = diff --git a/src/lib/binding/useObserve/useStore.ts b/src/lib/binding/useObserve/useStore.ts index d3f06d8..35c450e 100644 --- a/src/lib/binding/useObserve/useStore.ts +++ b/src/lib/binding/useObserve/useStore.ts @@ -22,7 +22,7 @@ type StoreReference = { * However this is "okay" since it is to be used inside a useSyncExternalStore and not directly in a render. */ export const useStore = ( - source$: Observable | (() => Observable | undefined), + source$: Observable | undefined | (() => Observable | undefined), options: UseObserveOptions, deps: DependencyList, ): ObservableStore => {