From 1e180b61983d2e398f0b254cf3ebd7d55c06c30e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 12:50:40 +0000 Subject: [PATCH 1/2] feat(useObserve): accept an undefined source The runtime already handled a factory returning `undefined`, but a source that is directly `undefined` was not part of the public overloads. This is a common case when the observable is not available yet (lazily created, from a state or a prop). `useObserve(undefined)` / `useObserve(source$ | undefined)` now typecheck and return the default value with a `complete` observable state, then start observing once an actual source is given (deps comparison recreates the store). Also allows passing `compareFn` alone (without `defaultValue`) for plain observables, which is needed for the `BehaviorSubject | undefined` case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112wLE8r3XNMqvKVSdD7Gvh --- src/lib/binding/useObserve/store.ts | 2 +- .../binding/useObserve/useObserve.test.tsx | 105 ++++++++++++++++++ src/lib/binding/useObserve/useObserve.ts | 35 +++++- src/lib/binding/useObserve/useStore.ts | 2 +- 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/lib/binding/useObserve/store.ts b/src/lib/binding/useObserve/store.ts index 057e237..1078aa3 100644 --- a/src/lib/binding/useObserve/store.ts +++ b/src/lib/binding/useObserve/store.ts @@ -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$ diff --git a/src/lib/binding/useObserve/useObserve.test.tsx b/src/lib/binding/useObserve/useObserve.test.tsx index f4394a8..73bb81c 100644 --- a/src/lib/binding/useObserve/useObserve.test.tsx +++ b/src/lib/binding/useObserve/useObserve.test.tsx @@ -315,4 +315,109 @@ 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 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 => { From 6acab0db05941e7cc085c45af7a951c4851c1ea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 13:02:10 +0000 Subject: [PATCH 2/2] perf(useObserve): skip the stream when there is no source Without a source the store state is already final (`success` / `complete`), which makes `subscribe` a no-op and `source$` unreachable. We were still building a `NEVER` pipe chain (distinctUntilChanged + tap + share) and keeping an open subscription on it until unmount, for a stream that can never emit. Assign a bare `NEVER` and `Subscription.EMPTY` instead. `store.sub.unsubscribe()` in useStore stays valid (EMPTY is already closed) and nothing reads `source$` from outside the store, so the observable state is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112wLE8r3XNMqvKVSdD7Gvh --- src/lib/binding/useObserve/store.ts | 17 +++++++++++++++-- src/lib/binding/useObserve/useObserve.test.tsx | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/binding/useObserve/store.ts b/src/lib/binding/useObserve/store.ts index 1078aa3..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" @@ -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 73bb81c..1eaf234 100644 --- a/src/lib/binding/useObserve/useObserve.test.tsx +++ b/src/lib/binding/useObserve/useObserve.test.tsx @@ -367,6 +367,22 @@ describe("useObserve", () => { }) }) + 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 }),