diff --git a/docs/utils/atom-with-observable.mdx b/docs/utils/atom-with-observable.mdx index 7d8e7a83d7..e2c39aeb06 100644 --- a/docs/utils/atom-with-observable.mdx +++ b/docs/utils/atom-with-observable.mdx @@ -9,6 +9,7 @@ Ref: https://github.com/pmndrs/jotai/pull/341 import { useAtom } from 'jotai' import { atomWithObservable } from 'jotai/utils' import { interval } from 'rxjs' +import { map } from "rxjs/operators" const counterSubject = interval(1000).pipe(map((i) => `#${i}`)); const counterAtom = atomWithObservable(() => counterSubject); @@ -24,5 +25,14 @@ Its value will be last value emitted from the stream. To use this atom, you need to wrap your component with ``. Check out [basics/async](../basics/async.mdx). +## Initial value +`atomWithObservable` takes second optional parameter `{ initialValue }` that allows to specify initial value for the atom. If `initialValue` is provided then `atomWithObservable` will not suspend and will show initial value before receiving first value from observable. `initialValue` can be either a value or a function that returns a value + +```ts +const counterAtom = atomWithObservable(() => counterSubject, {initialValue: 10}); + +const counterAtom2 = atomWithObservable(() => counterSubject, {initialValue: () => Math.random()}); +``` + ### Codesandbox diff --git a/src/utils/atomWithObservable.ts b/src/utils/atomWithObservable.ts index 8c487d063a..3df45451df 100644 --- a/src/utils/atomWithObservable.ts +++ b/src/utils/atomWithObservable.ts @@ -29,69 +29,49 @@ type ObservableLike = { type SubjectLike = ObservableLike & Observer +type InitialValueFunction = () => T | undefined + +type AtomWithObservableOptions = { + initialValue?: TData | InitialValueFunction +} + export function atomWithObservable( - createObservable: (get: Getter) => SubjectLike + createObservable: (get: Getter) => SubjectLike, + options?: AtomWithObservableOptions ): WritableAtom export function atomWithObservable( - createObservable: (get: Getter) => ObservableLike + createObservable: (get: Getter) => ObservableLike, + options?: AtomWithObservableOptions ): Atom export function atomWithObservable( - createObservable: (get: Getter) => ObservableLike | SubjectLike + createObservable: (get: Getter) => ObservableLike | SubjectLike, + options?: AtomWithObservableOptions ) { const observableResultAtom = atom((get) => { - let settlePromise: ((data: TData | null, err?: unknown) => void) | null = - null let observable = createObservable(get) const itself = observable[Symbol.observable]?.() if (itself) { observable = itself } - const dataAtom = atom>( - new Promise((resolve, reject) => { - settlePromise = (data, err) => { - if (err) { - reject(err) - } else { - resolve(data as TData) - } - } - }) + + const dataAtom = atom( + options?.initialValue + ? getInitialValue(options) + : firstValueFrom(observable) ) let setData: (data: TData | Promise) => void = () => { throw new Error('setting data without mount') } const dataListener = (data: TData) => { - if (settlePromise) { - settlePromise(data) - settlePromise = null - if (subscription && !setData) { - subscription.unsubscribe() - subscription = null - } - } else { - setData(data) - } + setData(data) } const errorListener = (error: unknown) => { - if (settlePromise) { - settlePromise(null, error) - settlePromise = null - if (subscription && !setData) { - subscription.unsubscribe() - subscription = null - } - } else { - setData(Promise.reject(error)) - } + setData(Promise.reject(error)) } let subscription: Subscription | null = null - subscription = observable.subscribe(dataListener, errorListener) - if (!settlePromise) { - subscription.unsubscribe() - subscription = null - } + dataAtom.onMount = (update) => { setData = update if (!subscription) { @@ -107,6 +87,7 @@ export function atomWithObservable( const observableAtom = atom( (get) => { const { dataAtom } = get(observableResultAtom) + return get(dataAtom) }, (get, _set, data: TData) => { @@ -120,3 +101,43 @@ export function atomWithObservable( ) return observableAtom } + +function getInitialValue(options: AtomWithObservableOptions) { + const initialValue = options.initialValue + return initialValue instanceof Function ? initialValue() : initialValue +} + +// FIXME There are two fatal issues in the current implememtation. +// See also: https://github.com/pmndrs/jotai/pull/1058 +// - There's a risk of memory leaks. +// Unless the source emit a new value, +// the subscription will never be destroyed. +// atom `read` function can be called multiple times without mounting. +// This issue has existed even before #1058. +// - The second value before mounting the atom is dropped. +// There's no guarantee that `onMount` is invoked in a short period. +// So, by the time we invoke `subscribe`, the value can be changed. +// Before #1058, an error was thrown, but currently it's silently dropped. +function firstValueFrom(source: ObservableLike): Promise { + return new Promise((resolve, reject) => { + let resolved = false + const subscription = source.subscribe({ + next: (value) => { + resolve(value) + resolved = true + if (subscription) { + subscription.unsubscribe() + } + }, + error: reject, + complete: () => { + reject() + }, + }) + + if (resolved) { + // If subscription was resolved synchronously + subscription.unsubscribe() + } + }) +} diff --git a/tests/utils/atomWithObservable.test.tsx b/tests/utils/atomWithObservable.test.tsx index dcd5a1b003..faaee00fdd 100644 --- a/tests/utils/atomWithObservable.test.tsx +++ b/tests/utils/atomWithObservable.test.tsx @@ -111,3 +111,93 @@ it('resubscribe on remount', async () => { act(() => subject.next(2)) await findByText('count: 2') }) + +it("count state with initialValue doesn't suspend", async () => { + const subject = new Subject() + const observableAtom = atomWithObservable(() => subject, { initialValue: 5 }) + + const Counter = () => { + const [state] = useAtom(observableAtom) + + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 5') + + act(() => subject.next(10)) + + await findByText('count: 10') +}) + +it('writable count state with initialValue', async () => { + const observableAtom = atomWithObservable( + () => { + const observable = new Observable((subscriber) => { + subscriber.next(1) + }) + const subject = new Subject() + // is this usual to delay the subscription? + setTimeout(() => { + observable.subscribe(subject) + }, 100) + return subject + }, + { initialValue: 5 } + ) + + const Counter = () => { + const [state, dispatch] = useAtom(observableAtom) + + return ( + <> + count: {state} + + + ) + } + + const { findByText, getByText } = render( + + + + + + ) + + await findByText('count: 5') + + await findByText('count: 1') + + fireEvent.click(getByText('button')) + await findByText('count: 9') +}) + +it('with initial value and synchronous subscription', async () => { + const observableAtom = atomWithObservable( + () => + new Observable((subscriber) => { + subscriber.next(1) + }), + { initialValue: 5 } + ) + + const Counter = () => { + const [state] = useAtom(observableAtom) + + return <>count: {state} + } + + const { findByText } = render( + + + + ) + + await findByText('count: 1') +})