From 6be02b010eab0e62dc4cd1687bea5628d6467db8 Mon Sep 17 00:00:00 2001 From: daishi Date: Wed, 7 Aug 2024 10:37:36 +0900 Subject: [PATCH 01/15] add failing test for #2682 --- tests/react/async2.test.tsx | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/react/async2.test.tsx b/tests/react/async2.test.tsx index 0578f713d0..4bfcede5e0 100644 --- a/tests/react/async2.test.tsx +++ b/tests/react/async2.test.tsx @@ -267,3 +267,103 @@ describe('infinite pending', () => { await findByText('count: 3') }) }) + +describe('write to async atom twice', async () => { + it('no wait', async () => { + const asyncAtom = atom(Promise.resolve(2)) + const writer = atom(null, async (get, set) => { + set(asyncAtom, async (c) => (await c) + 1) + set(asyncAtom, async (c) => (await c) + 1) + return get(asyncAtom) + }) + + const Component = () => { + const count = useAtomValue(asyncAtom) + const write = useSetAtom(writer) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + , + ) + + await findByText('count: 2') + await userEvent.click(getByText('button')) + await findByText('count: 4') + }) + + it('wait Promise.resolve()', async () => { + const asyncAtom = atom(Promise.resolve(2)) + const writer = atom(null, async (get, set) => { + set(asyncAtom, async (c) => (await c) + 1) + await Promise.resolve() + set(asyncAtom, async (c) => (await c) + 1) + return get(asyncAtom) + }) + + const Component = () => { + const count = useAtomValue(asyncAtom) + const write = useSetAtom(writer) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + , + ) + + await findByText('count: 2') + await userEvent.click(getByText('button')) + await findByText('count: 4') + }) + + it('wait setTimeout()', async () => { + const asyncAtom = atom(Promise.resolve(2)) + const writer = atom(null, async (get, set) => { + set(asyncAtom, async (c) => (await c) + 1) + await new Promise((r) => setTimeout(r)) + set(asyncAtom, async (c) => (await c) + 1) + return get(asyncAtom) + }) + + const Component = () => { + const count = useAtomValue(asyncAtom) + const write = useSetAtom(writer) + return ( + <> +
count: {count}
+ + + ) + } + + const { findByText, getByText } = render( + + + + + , + ) + + await findByText('count: 2') + await userEvent.click(getByText('button')) + await findByText('count: 4') + }) +}) From 1b170cd079b2f9727211c01483145522afd8e5c4 Mon Sep 17 00:00:00 2001 From: daishi Date: Sat, 24 Aug 2024 10:47:18 +0900 Subject: [PATCH 02/15] wip: continuable promise in useAtomValue --- src/react/useAtomValue.ts | 58 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index 8c28b62ebd..82555d470a 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -41,6 +41,54 @@ const use = } }) +const continuablePromiseMap = new WeakMap< + PromiseLike, + Promise +>() + +const createContinuablePromise = ( + promise: PromiseLike, + getLatest: () => PromiseLike, +) => { + let continuablePromise = continuablePromiseMap.get(promise) + if (!continuablePromise) { + continuablePromise = new Promise((resolve, reject) => { + let curr = promise + const onFulfilled = (me: PromiseLike) => (v: T) => { + if (curr === me) { + resolve(v) + } + } + const onRejected = (me: PromiseLike) => (e: unknown) => { + if (curr === me) { + reject(e) + } + } + promise.then(onFulfilled(promise), onRejected(promise)) + const signal = + 'signal' in promise && + promise.signal instanceof AbortSignal && + promise.signal + if (signal) { + signal.addEventListener('abort', () => { + const nextPromise = getLatest() + if ( + import.meta.env?.MODE !== 'production' && + nextPromise === promise + ) { + throw new Error('[Bug] promise is not updated even after aborting') + } + continuablePromiseMap.set(nextPromise, continuablePromise!) + curr = nextPromise + nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) + }) + } + }) + continuablePromiseMap.set(promise, continuablePromise) + } + return continuablePromise +} + type Options = Parameters[0] & { delay?: number } @@ -99,8 +147,14 @@ export function useAtomValue(atom: Atom, options?: Options) { }, [store, atom, delay]) useDebugValue(value) - // TS doesn't allow using `use` always. // The use of isPromiseLike is to be consistent with `use` type. // `instanceof Promise` actually works fine in this case. - return isPromiseLike(value) ? use(value) : (value as Awaited) + if (isPromiseLike(value)) { + const promise = createContinuablePromise( + value, + () => store.get(atom) as typeof value, + ) + return use(promise) + } + return value as Awaited } From 6317b70ea0bb6dbba48c7ef8de1fd94ce6d42212 Mon Sep 17 00:00:00 2001 From: daishi Date: Sat, 24 Aug 2024 13:16:38 +0900 Subject: [PATCH 03/15] wip: abortable promise --- src/react/useAtomValue.ts | 8 +- src/vanilla/store.ts | 194 +++++++++++++------------------------- 2 files changed, 70 insertions(+), 132 deletions(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index 82555d470a..592c26c589 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -150,10 +150,10 @@ export function useAtomValue(atom: Atom, options?: Options) { // The use of isPromiseLike is to be consistent with `use` type. // `instanceof Promise` actually works fine in this case. if (isPromiseLike(value)) { - const promise = createContinuablePromise( - value, - () => store.get(atom) as typeof value, - ) + const promise = createContinuablePromise(value, () => { + const nextValue = store.get(atom) + return isPromiseLike(nextValue) ? nextValue : Promise.resolve(nextValue) + }) return use(promise) } return value as Awaited diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 7b04acdbae..b48ef7e165 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -20,89 +20,47 @@ const isActuallyWritableAtom = (atom: AnyAtom): atom is AnyWritableAtom => !!(atom as AnyWritableAtom).write // -// Continuable Promise +// Abortable Promise // -const CONTINUE_PROMISE = Symbol( - import.meta.env?.MODE !== 'production' ? 'CONTINUE_PROMISE' : '', -) - -const PENDING = 'pending' -const FULFILLED = 'fulfilled' -const REJECTED = 'rejected' - -type ContinuePromise = ( - nextPromise: PromiseLike | undefined, - nextAbort: () => void, -) => void - -type ContinuablePromise = Promise & - ( - | { status: typeof PENDING } - | { status: typeof FULFILLED; value?: T } - | { status: typeof REJECTED; reason?: AnyError } - ) & { - [CONTINUE_PROMISE]: ContinuePromise - } +type PromiseState = [controller: AbortController, settled: boolean] -const isContinuablePromise = ( - promise: unknown, -): promise is ContinuablePromise => - typeof promise === 'object' && promise !== null && CONTINUE_PROMISE in promise +const abortablePromiseMap = new WeakMap, PromiseState>() -const continuablePromiseMap: WeakMap< - PromiseLike, - ContinuablePromise -> = new WeakMap() +const isPromisePending = (promise: PromiseLike) => + !abortablePromiseMap.get(promise)?.[1] -/** - * Create a continuable promise from a regular promise. - */ -const createContinuablePromise = ( - promise: PromiseLike, - abort: () => void, - complete: () => void, -): ContinuablePromise => { - if (!continuablePromiseMap.has(promise)) { - let continuePromise: ContinuePromise - const p: any = new Promise((resolve, reject) => { - let curr = promise - const onFulfilled = (me: PromiseLike) => (v: T) => { - if (curr === me) { - p.status = FULFILLED - p.value = v - resolve(v) - complete() - } - } - const onRejected = (me: PromiseLike) => (e: AnyError) => { - if (curr === me) { - p.status = REJECTED - p.reason = e - reject(e) - complete() - } - } - promise.then(onFulfilled(promise), onRejected(promise)) - continuePromise = (nextPromise, nextAbort) => { - if (nextPromise) { - continuablePromiseMap.set(nextPromise, p) - curr = nextPromise - nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) - - // Only abort promises that aren't user-facing. When nextPromise is set, - // we can replace the current promise with the next one, so we don't - // see any abort-related errors. - abort() - abort = nextAbort - } - } - }) - p.status = PENDING - p[CONTINUE_PROMISE] = continuePromise! - continuablePromiseMap.set(promise, p) +const abortPromise = (promise: PromiseLike) => { + const promiseState = abortablePromiseMap.get(promise) + if (promiseState) { + promiseState[0].abort() + promiseState[1] = true + } else if (import.meta.env?.MODE !== 'production') { + throw new Error('[Bug] abortable promise not found') + } +} + +const registerAbort = (promise: PromiseLike, abort: () => void) => { + const promiseState = abortablePromiseMap.get(promise) + if (promiseState) { + promiseState[0].signal.addEventListener('abort', abort) + } else if (import.meta.env?.MODE !== 'production') { + throw new Error('[Bug] abortable promise not found') + } +} + +const patchPromiseForAbortability = (promise: PromiseLike) => { + if (abortablePromiseMap.has(promise)) { + // already patched + return + } + const promiseState: PromiseState = [new AbortController(), false] + abortablePromiseMap.set(promise, promiseState) + const settle = () => { + promiseState![1] = true } - return continuablePromiseMap.get(promise) as ContinuablePromise + promise.then(settle, settle) + ;(promise as { signal?: AbortSignal }).signal = promiseState[0].signal } const isPromiseLike = (x: unknown): x is PromiseLike => @@ -165,17 +123,17 @@ const returnAtomValue = (atomState: AtomState): Value => { return atomState.v! } -const getPendingContinuablePromise = (atomState: AtomState) => { +const getPendingPromise = (atomState: AtomState) => { const value: unknown = atomState.v - if (isContinuablePromise(value) && value.status === PENDING) { + if (isPromiseLike(value) && isPromisePending(value)) { return value } return null } -const addPendingContinuablePromiseToDependency = ( +const addPendingPromiseToDependency = ( atom: AnyAtom, - promise: ContinuablePromise & { status: typeof PENDING }, + promise: PromiseLike, dependencyAtomState: AtomState, ) => { if (!dependencyAtomState.p.has(atom)) { @@ -202,9 +160,9 @@ const addDependency = ( throw new Error('[Bug] atom cannot depend on itself') } atomState.d.set(a, aState.n) - const continuablePromise = getPendingContinuablePromise(atomState) - if (continuablePromise) { - addPendingContinuablePromiseToDependency(atom, continuablePromise, aState) + const abortablePromise = getPendingPromise(atomState) + if (abortablePromise) { + addPendingPromiseToDependency(atom, abortablePromise, aState) } aState.m?.t.add(atom) if (pending) { @@ -312,49 +270,31 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { atom: AnyAtom, atomState: AtomState, valueOrPromise: unknown, - abortPromise = () => {}, - completePromise = () => {}, ) => { const hasPrevValue = 'v' in atomState const prevValue = atomState.v - const pendingPromise = getPendingContinuablePromise(atomState) + const pendingPromise = getPendingPromise(atomState) if (isPromiseLike(valueOrPromise)) { - if (pendingPromise) { - if (pendingPromise !== valueOrPromise) { - pendingPromise[CONTINUE_PROMISE](valueOrPromise, abortPromise) - ++atomState.n - } - } else { - const continuablePromise = createContinuablePromise( + patchPromiseForAbortability(valueOrPromise) + for (const a of atomState.d.keys()) { + addPendingPromiseToDependency( + atom, valueOrPromise, - abortPromise, - completePromise, + getAtomState(a, atomState), ) - if (continuablePromise.status === PENDING) { - for (const a of atomState.d.keys()) { - addPendingContinuablePromiseToDependency( - atom, - continuablePromise, - getAtomState(a, atomState), - ) - } - } - atomState.v = continuablePromise - delete atomState.e } + atomState.v = valueOrPromise + delete atomState.e } else { - if (pendingPromise) { - pendingPromise[CONTINUE_PROMISE]( - Promise.resolve(valueOrPromise), - abortPromise, - ) - } atomState.v = valueOrPromise delete atomState.e } if (!hasPrevValue || !Object.is(prevValue, atomState.v)) { ++atomState.n } + if (pendingPromise) { + abortPromise(pendingPromise) + } } const readAtomState = ( @@ -448,19 +388,18 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { } try { const valueOrPromise = atom.read(getter, options as never) - setAtomStateValueOrPromise( - atom, - atomState, - valueOrPromise, - () => controller?.abort(), - () => { + setAtomStateValueOrPromise(atom, atomState, valueOrPromise) + if (isPromiseLike(valueOrPromise)) { + registerAbort(valueOrPromise, () => controller?.abort()) + const complete = () => { if (atomState.m) { const pending = createPending() mountDependencies(pending, atom, atomState) flushPending(pending) } - }, - ) + } + valueOrPromise.then(complete, complete) + } return atomState } catch (error) { delete atomState.v @@ -484,10 +423,10 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { for (const a of atomState.m?.t || []) { dependents.set(a, getAtomState(a, atomState)) } - for (const atomWithPendingContinuablePromise of atomState.p) { + for (const atomWithPendingAbortablePromise of atomState.p) { dependents.set( - atomWithPendingContinuablePromise, - getAtomState(atomWithPendingContinuablePromise, atomState), + atomWithPendingAbortablePromise, + getAtomState(atomWithPendingAbortablePromise, atomState), ) } getPendingDependents(pending, atom)?.forEach((dependent) => { @@ -609,7 +548,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { atom: AnyAtom, atomState: AtomState, ) => { - if (atomState.m && !getPendingContinuablePromise(atomState)) { + if (atomState.m && !getPendingPromise(atomState)) { for (const a of atomState.d.keys()) { if (!atomState.m.d.has(a)) { const aMounted = mountAtom(pending, a, getAtomState(a, atomState)) @@ -692,10 +631,9 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { aMounted?.t.delete(atom) } // abort pending promise - const pendingPromise = getPendingContinuablePromise(atomState) + const pendingPromise = getPendingPromise(atomState) if (pendingPromise) { - // FIXME using `undefined` is kind of a hack. - pendingPromise[CONTINUE_PROMISE](undefined, () => {}) + abortPromise(pendingPromise) } return undefined } From 8fed07dcc5acb2bdf49acd4b37f57e7cd41a016f Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 11:21:29 +0900 Subject: [PATCH 04/15] wip fix store test --- src/vanilla/store.ts | 5 --- tests/vanilla/store.test.tsx | 73 +++++++++++++++--------------------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index b48ef7e165..447d6d460b 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -630,11 +630,6 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { const aMounted = unmountAtom(pending, a, getAtomState(a, atomState)) aMounted?.t.delete(atom) } - // abort pending promise - const pendingPromise = getPendingPromise(atomState) - if (pendingPromise) { - abortPromise(pendingPromise) - } return undefined } return atomState.m diff --git a/tests/vanilla/store.test.tsx b/tests/vanilla/store.test.tsx index 4514cc8a3c..59ccb0672a 100644 --- a/tests/vanilla/store.test.tsx +++ b/tests/vanilla/store.test.tsx @@ -87,9 +87,11 @@ it('should override a promise by setting', async () => { const countAtom = atom(Promise.resolve(0)) const infinitePending = new Promise(() => {}) store.set(countAtom, infinitePending) - const promise = store.get(countAtom) + const promise1 = store.get(countAtom) + expect(promise1).toBe(infinitePending) store.set(countAtom, Promise.resolve(1)) - expect(await promise).toBe(1) + const promise2 = store.get(countAtom) + expect(await promise2).toBe(1) }) it('should update async atom with deps after await (#1905)', async () => { @@ -417,31 +419,37 @@ it('should flush pending write triggered asynchronously and indirectly (#2451)', describe('async atom with subtle timing', () => { it('case 1', async () => { const store = createStore() - let resolve = () => {} + const resolve: (() => void)[] = [] const a = atom(1) const b = atom(async (get) => { - await new Promise((r) => (resolve = r)) + await new Promise((r) => resolve.push(r)) return get(a) }) const bValue = store.get(b) store.set(a, 2) - resolve() + resolve.splice(0).forEach((fn) => fn()) + const bValue2 = store.get(b) + resolve.splice(0).forEach((fn) => fn()) expect(await bValue).toBe(2) + expect(await bValue2).toBe(2) }) it('case 2', async () => { const store = createStore() - let resolve = () => {} + const resolve: (() => void)[] = [] const a = atom(1) const b = atom(async (get) => { const aValue = get(a) - await new Promise((r) => (resolve = r)) + await new Promise((r) => resolve.push(r)) return aValue }) const bValue = store.get(b) store.set(a, 2) - resolve() - expect(await bValue).toBe(2) + resolve.splice(0).forEach((fn) => fn()) + const bValue2 = store.get(b) + resolve.splice(0).forEach((fn) => fn()) + expect(await bValue).toBe(1) // returns old value + expect(await bValue2).toBe(2) }) }) @@ -458,32 +466,26 @@ describe('aborting atoms', () => { const a = atom(1) const callBeforeAbort = vi.fn() const callAfterAbort = vi.fn() - let resolve = () => {} + const resolve: (() => void)[] = [] const store = createStore() const derivedAtom = atom(async (get, { signal }) => { const aVal = get(a) - - await new Promise((r) => (resolve = r)) - + await new Promise((r) => resolve.push(r)) callBeforeAbort() - throwIfAborted(signal) - callAfterAbort() - return aVal + 1 }) const promise = store.get(derivedAtom) - const firstResolve = resolve store.set(a, 3) + const promise2 = store.get(derivedAtom) - firstResolve() - resolve() - expect(await promise).toEqual(4) - + resolve.splice(0).forEach((fn) => fn()) + expect(promise).rejects.toThrow('aborted') + expect(await promise2).toEqual(4) expect(callBeforeAbort).toHaveBeenCalledTimes(2) expect(callAfterAbort).toHaveBeenCalledTimes(1) }) @@ -492,33 +494,24 @@ describe('aborting atoms', () => { const a = atom(1) const callBeforeAbort = vi.fn() const callAfterAbort = vi.fn() - let resolve = () => {} + const resolve: (() => void)[] = [] const store = createStore() const derivedAtom = atom(async (get, { signal }) => { const aVal = get(a) - - await new Promise((r) => (resolve = r)) - + await new Promise((r) => resolve.push(r)) callBeforeAbort() - throwIfAborted(signal) - callAfterAbort() - return aVal + 1 }) store.sub(derivedAtom, () => {}) - const firstResolve = resolve store.set(a, 3) - firstResolve() - resolve() - - await new Promise(setImmediate) - + resolve.splice(0).forEach((fn) => fn()) + await new Promise((r) => setTimeout(r)) // wait for a tick expect(callBeforeAbort).toHaveBeenCalledTimes(2) expect(callAfterAbort).toHaveBeenCalledTimes(1) }) @@ -527,28 +520,22 @@ describe('aborting atoms', () => { const a = atom(1) const callBeforeAbort = vi.fn() const callAfterAbort = vi.fn() - let resolve = () => {} + const resolve: (() => void)[] = [] const store = createStore() const derivedAtom = atom(async (get, { signal }) => { const aVal = get(a) - - await new Promise((r) => (resolve = r)) - + await new Promise((r) => resolve.push(r)) callBeforeAbort() - throwIfAborted(signal) - callAfterAbort() - return aVal + 1 }) const unsub = store.sub(derivedAtom, () => {}) - unsub() - resolve() + resolve.splice(0).forEach((fn) => fn()) expect(await store.get(derivedAtom)).toEqual(2) expect(callBeforeAbort).toHaveBeenCalledTimes(1) From 3089d0ab8d9703ecd7b771f5447aae69d589a732 Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 11:30:24 +0900 Subject: [PATCH 05/15] wip: fix dependency test --- tests/vanilla/dependency.test.tsx | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/vanilla/dependency.test.tsx b/tests/vanilla/dependency.test.tsx index 5de4a9927e..31c5e2edb5 100644 --- a/tests/vanilla/dependency.test.tsx +++ b/tests/vanilla/dependency.test.tsx @@ -201,25 +201,18 @@ it('settles never resolving async derivations with deps picked up sync', async ( let sub = 0 const values: unknown[] = [] store.get(asyncAtom).then((value) => values.push(value)) - store.sub(asyncAtom, () => { sub++ store.get(asyncAtom).then((value) => values.push(value)) }) - await new Promise((r) => setTimeout(r)) - store.set(syncAtom, { promise: new Promise((r) => resolve.push(r)), }) - - await new Promise((r) => setTimeout(r)) - resolve[1]?.(1) - await new Promise((r) => setTimeout(r)) - - expect(values).toEqual([1, 1]) + await new Promise((r) => setTimeout(r)) // wait for a tick + expect(values).toEqual([1]) expect(sub).toBe(1) }) @@ -233,7 +226,6 @@ it('settles never resolving async derivations with deps picked up async', async const asyncAtom = atom(async (get) => { // we want to pick up `syncAtom` as an async dep await Promise.resolve() - return await get(syncAtom).promise }) @@ -242,24 +234,18 @@ it('settles never resolving async derivations with deps picked up async', async let sub = 0 const values: unknown[] = [] store.get(asyncAtom).then((value) => values.push(value)) - store.sub(asyncAtom, () => { sub++ store.get(asyncAtom).then((value) => values.push(value)) }) - await new Promise((r) => setTimeout(r)) - + await new Promise((r) => setTimeout(r)) // wait for a tick store.set(syncAtom, { promise: new Promise((r) => resolve.push(r)), }) - - await new Promise((r) => setTimeout(r)) - resolve[1]?.(1) - await new Promise((r) => setTimeout(r)) - - expect(values).toEqual([1, 1]) + await new Promise((r) => setTimeout(r)) // wait for a tick + expect(values).toEqual([1]) expect(sub).toBe(1) }) From 7e45b3a96d9211db7b72a65057178efd6da60ba9 Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 12:02:58 +0900 Subject: [PATCH 06/15] wip: fix continuable promise --- src/react/useAtomValue.ts | 31 ++++++++++++++----------------- src/vanilla/store.ts | 6 +++--- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index 592c26c589..ffd4efddf5 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -65,24 +65,21 @@ const createContinuablePromise = ( } } promise.then(onFulfilled(promise), onRejected(promise)) - const signal = - 'signal' in promise && - promise.signal instanceof AbortSignal && - promise.signal - if (signal) { - signal.addEventListener('abort', () => { - const nextPromise = getLatest() - if ( - import.meta.env?.MODE !== 'production' && - nextPromise === promise - ) { - throw new Error('[Bug] promise is not updated even after aborting') - } - continuablePromiseMap.set(nextPromise, continuablePromise!) - curr = nextPromise - nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) - }) + const addAbortListener = (p: PromiseLike) => { + if ('signal' in p && p.signal instanceof AbortSignal) { + p.signal.addEventListener('abort', () => { + const nextPromise = getLatest() + if (import.meta.env?.MODE !== 'production' && nextPromise === p) { + throw new Error('[Bug] p is not updated even after aborting') + } + continuablePromiseMap.set(nextPromise, continuablePromise!) + curr = nextPromise + nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) + addAbortListener(nextPromise) + }) + } } + addAbortListener(promise) }) continuablePromiseMap.set(promise, continuablePromise) } diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 447d6d460b..668ba243d4 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -291,9 +291,9 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { } if (!hasPrevValue || !Object.is(prevValue, atomState.v)) { ++atomState.n - } - if (pendingPromise) { - abortPromise(pendingPromise) + if (pendingPromise) { + abortPromise(pendingPromise) + } } } From 57907cfda0731742e733c1be8b76bb80b5e80576 Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 12:21:09 +0900 Subject: [PATCH 07/15] fix unwrap test --- src/vanilla/utils/unwrap.ts | 26 +++++++------------------- tests/vanilla/utils/unwrap.test.ts | 2 ++ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/vanilla/utils/unwrap.ts b/src/vanilla/utils/unwrap.ts index fe6269636d..3313980171 100644 --- a/src/vanilla/utils/unwrap.ts +++ b/src/vanilla/utils/unwrap.ts @@ -9,13 +9,7 @@ const memo2 = (create: () => T, dep1: object, dep2: object): T => { return getCached(create, cache2, dep2) } -type PromiseMeta = - | { status?: 'pending' } - | { status: 'fulfilled'; value: unknown } - | { status: 'rejected'; reason: unknown } - -const isPromise = (x: unknown): x is Promise & PromiseMeta => - x instanceof Promise +const isPromise = (x: unknown): x is Promise => x instanceof Promise const defaultFallback = () => undefined @@ -66,18 +60,12 @@ export function unwrap( return { v: promise as Awaited } } if (promise !== prev?.p) { - if (promise.status === 'fulfilled') { - promiseResultCache.set(promise, promise.value as Awaited) - } else if (promise.status === 'rejected') { - promiseErrorCache.set(promise, promise.reason) - } else { - promise - .then( - (v) => promiseResultCache.set(promise, v as Awaited), - (e) => promiseErrorCache.set(promise, e), - ) - .finally(setSelf) - } + promise + .then( + (v) => promiseResultCache.set(promise, v as Awaited), + (e) => promiseErrorCache.set(promise, e), + ) + .finally(setSelf) } if (promiseErrorCache.has(promise)) { throw promiseErrorCache.get(promise) diff --git a/tests/vanilla/utils/unwrap.test.ts b/tests/vanilla/utils/unwrap.test.ts index 31af69abf6..8703638d4f 100644 --- a/tests/vanilla/utils/unwrap.test.ts +++ b/tests/vanilla/utils/unwrap.test.ts @@ -133,6 +133,8 @@ describe('unwrap', () => { const asyncAtom = atom(Promise.resolve('concrete')) expect(await store.get(asyncAtom)).toEqual('concrete') + store.get(unwrap(asyncAtom)) // initially pending + await new Promise((r) => setTimeout(r)) // wait for a tick expect(store.get(unwrap(asyncAtom))).toEqual('concrete') }) }) From 5c126f775e6ad5776476600bbcd4676f0522c9ab Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 12:25:11 +0900 Subject: [PATCH 08/15] fix loadable tes --- src/vanilla/utils/loadable.ts | 39 +++++++++------------------- tests/vanilla/utils/loadable.test.ts | 4 +++ tests/vanilla/utils/unwrap.test.ts | 2 +- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/vanilla/utils/loadable.ts b/src/vanilla/utils/loadable.ts index e2c7401600..135bfccd56 100644 --- a/src/vanilla/utils/loadable.ts +++ b/src/vanilla/utils/loadable.ts @@ -5,14 +5,8 @@ const cache1 = new WeakMap() const memo1 = (create: () => T, dep1: object): T => (cache1.has(dep1) ? cache1 : cache1.set(dep1, create())).get(dep1) -type PromiseMeta = - | { status?: 'pending' } - | { status: 'fulfilled'; value: Awaited } - | { status: 'rejected'; reason: unknown } - -const isPromise = ( - x: unknown, -): x is Promise> & PromiseMeta => x instanceof Promise +const isPromise = (x: unknown): x is Promise> => + x instanceof Promise export type Loadable = | { state: 'loading' } @@ -50,25 +44,16 @@ export function loadable(anAtom: Atom): Atom> { if (cached1) { return cached1 } - if (promise.status === 'fulfilled') { - loadableCache.set(promise, { state: 'hasData', data: promise.value }) - } else if (promise.status === 'rejected') { - loadableCache.set(promise, { - state: 'hasError', - error: promise.reason, - }) - } else { - promise - .then( - (data) => { - loadableCache.set(promise, { state: 'hasData', data }) - }, - (error) => { - loadableCache.set(promise, { state: 'hasError', error }) - }, - ) - .finally(setSelf) - } + promise + .then( + (data) => { + loadableCache.set(promise, { state: 'hasData', data }) + }, + (error) => { + loadableCache.set(promise, { state: 'hasError', error }) + }, + ) + .finally(setSelf) const cached2 = loadableCache.get(promise) if (cached2) { return cached2 diff --git a/tests/vanilla/utils/loadable.test.ts b/tests/vanilla/utils/loadable.test.ts index b0072e385e..484343c8b4 100644 --- a/tests/vanilla/utils/loadable.test.ts +++ b/tests/vanilla/utils/loadable.test.ts @@ -8,6 +8,10 @@ describe('loadable', () => { const asyncAtom = atom(Promise.resolve('concrete')) expect(await store.get(asyncAtom)).toEqual('concrete') + expect(store.get(loadable(asyncAtom))).toEqual({ + state: 'loading', + }) + await new Promise((r) => setTimeout(r)) // wait for a tick expect(store.get(loadable(asyncAtom))).toEqual({ state: 'hasData', data: 'concrete', diff --git a/tests/vanilla/utils/unwrap.test.ts b/tests/vanilla/utils/unwrap.test.ts index 8703638d4f..a0ca0ed8f5 100644 --- a/tests/vanilla/utils/unwrap.test.ts +++ b/tests/vanilla/utils/unwrap.test.ts @@ -133,7 +133,7 @@ describe('unwrap', () => { const asyncAtom = atom(Promise.resolve('concrete')) expect(await store.get(asyncAtom)).toEqual('concrete') - store.get(unwrap(asyncAtom)) // initially pending + expect(store.get(unwrap(asyncAtom))).toEqual(undefined) await new Promise((r) => setTimeout(r)) // wait for a tick expect(store.get(unwrap(asyncAtom))).toEqual('concrete') }) From 4e0ac62e7e3b14ce3911e0d711d8b3c3d26e877a Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 13:27:03 +0900 Subject: [PATCH 09/15] fix with attachPromiseMeta --- src/react/useAtomValue.ts | 50 ++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index ffd4efddf5..d7bee3a8fc 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -10,6 +10,26 @@ type Store = ReturnType const isPromiseLike = (x: unknown): x is PromiseLike => typeof (x as any)?.then === 'function' +const attachPromiseMeta = ( + promise: PromiseLike & { + status?: 'pending' | 'fulfilled' | 'rejected' + value?: T + reason?: unknown + }, +) => { + promise.status = 'pending' + promise.then( + (v) => { + promise.status = 'fulfilled' + promise.value = v + }, + (e) => { + promise.status = 'rejected' + promise.reason = e + }, + ) +} + const use = ReactExports.use || (( @@ -26,17 +46,7 @@ const use = } else if (promise.status === 'rejected') { throw promise.reason } else { - promise.status = 'pending' - promise.then( - (v) => { - promise.status = 'fulfilled' - promise.value = v - }, - (e) => { - promise.status = 'rejected' - promise.reason = e - }, - ) + attachPromiseMeta(promise) throw promise } }) @@ -48,7 +58,7 @@ const continuablePromiseMap = new WeakMap< const createContinuablePromise = ( promise: PromiseLike, - getLatest: () => PromiseLike, + getLatest: () => PromiseLike | T, ) => { let continuablePromise = continuablePromiseMap.get(promise) if (!continuablePromise) { @@ -68,7 +78,10 @@ const createContinuablePromise = ( const addAbortListener = (p: PromiseLike) => { if ('signal' in p && p.signal instanceof AbortSignal) { p.signal.addEventListener('abort', () => { - const nextPromise = getLatest() + const nextValue = getLatest() + const nextPromise = isPromiseLike(nextValue) + ? nextValue + : Promise.resolve(nextValue) if (import.meta.env?.MODE !== 'production' && nextPromise === p) { throw new Error('[Bug] p is not updated even after aborting') } @@ -133,6 +146,12 @@ export function useAtomValue(atom: Atom, options?: Options) { useEffect(() => { const unsub = store.sub(atom, () => { if (typeof delay === 'number') { + const value = store.get(atom) + if (isPromiseLike(value)) { + attachPromiseMeta( + createContinuablePromise(value, () => store.get(atom)), + ) + } // delay rerendering to wait a promise possibly to resolve setTimeout(rerender, delay) return @@ -147,10 +166,7 @@ export function useAtomValue(atom: Atom, options?: Options) { // The use of isPromiseLike is to be consistent with `use` type. // `instanceof Promise` actually works fine in this case. if (isPromiseLike(value)) { - const promise = createContinuablePromise(value, () => { - const nextValue = store.get(atom) - return isPromiseLike(nextValue) ? nextValue : Promise.resolve(nextValue) - }) + const promise = createContinuablePromise(value, () => store.get(atom)) return use(promise) } return value as Awaited From 4d97ec62b29dffb36bf4ce0d00e03dec08dc94b8 Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 25 Aug 2024 15:23:08 +0900 Subject: [PATCH 10/15] refactor --- src/react/useAtomValue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index d7bee3a8fc..6011c7e643 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -74,7 +74,6 @@ const createContinuablePromise = ( reject(e) } } - promise.then(onFulfilled(promise), onRejected(promise)) const addAbortListener = (p: PromiseLike) => { if ('signal' in p && p.signal instanceof AbortSignal) { p.signal.addEventListener('abort', () => { @@ -92,6 +91,7 @@ const createContinuablePromise = ( }) } } + promise.then(onFulfilled(promise), onRejected(promise)) addAbortListener(promise) }) continuablePromiseMap.set(promise, continuablePromise) From abfa2f136417bce2d3ce3e928437d2249ffe5e6a Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 26 Aug 2024 09:53:49 +0900 Subject: [PATCH 11/15] eliminate abort controller for promise --- src/react/useAtomValue.ts | 12 ++++---- src/vanilla/store.ts | 59 ++++++++++++++++++--------------------- 2 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index 6011c7e643..d207187116 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -74,25 +74,25 @@ const createContinuablePromise = ( reject(e) } } - const addAbortListener = (p: PromiseLike) => { - if ('signal' in p && p.signal instanceof AbortSignal) { - p.signal.addEventListener('abort', () => { + const registerCancelHandler = (p: PromiseLike) => { + if ('onCancel' in p && typeof p.onCancel === 'function') { + p.onCancel(() => { const nextValue = getLatest() const nextPromise = isPromiseLike(nextValue) ? nextValue : Promise.resolve(nextValue) if (import.meta.env?.MODE !== 'production' && nextPromise === p) { - throw new Error('[Bug] p is not updated even after aborting') + throw new Error('[Bug] p is not updated even after cancelation') } continuablePromiseMap.set(nextPromise, continuablePromise!) curr = nextPromise nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) - addAbortListener(nextPromise) + registerCancelHandler(nextPromise) }) } } promise.then(onFulfilled(promise), onRejected(promise)) - addAbortListener(promise) + registerCancelHandler(promise) }) continuablePromiseMap.set(promise, continuablePromise) } diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 668ba243d4..333177bdc0 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -20,50 +20,45 @@ const isActuallyWritableAtom = (atom: AnyAtom): atom is AnyWritableAtom => !!(atom as AnyWritableAtom).write // -// Abortable Promise +// Cancelable Promise // -type PromiseState = [controller: AbortController, settled: boolean] +type CancelHandler = () => void +type PromiseState = [cancelHandlers: Set, settled: boolean] -const abortablePromiseMap = new WeakMap, PromiseState>() +const cancelablePromiseMap = new WeakMap, PromiseState>() const isPromisePending = (promise: PromiseLike) => - !abortablePromiseMap.get(promise)?.[1] + !cancelablePromiseMap.get(promise)?.[1] -const abortPromise = (promise: PromiseLike) => { - const promiseState = abortablePromiseMap.get(promise) +const cancelPromise = (promise: PromiseLike) => { + const promiseState = cancelablePromiseMap.get(promise) if (promiseState) { - promiseState[0].abort() + promiseState[0].forEach((fn) => fn()) promiseState[1] = true } else if (import.meta.env?.MODE !== 'production') { - throw new Error('[Bug] abortable promise not found') + throw new Error('[Bug] cancelable promise not found') } } -const registerAbort = (promise: PromiseLike, abort: () => void) => { - const promiseState = abortablePromiseMap.get(promise) - if (promiseState) { - promiseState[0].signal.addEventListener('abort', abort) - } else if (import.meta.env?.MODE !== 'production') { - throw new Error('[Bug] abortable promise not found') - } -} - -const patchPromiseForAbortability = (promise: PromiseLike) => { - if (abortablePromiseMap.has(promise)) { +const patchPromiseForCancelability = (promise: PromiseLike) => { + if (cancelablePromiseMap.has(promise)) { // already patched return } - const promiseState: PromiseState = [new AbortController(), false] - abortablePromiseMap.set(promise, promiseState) + const promiseState: PromiseState = [new Set(), false] + cancelablePromiseMap.set(promise, promiseState) const settle = () => { promiseState![1] = true } promise.then(settle, settle) - ;(promise as { signal?: AbortSignal }).signal = promiseState[0].signal + ;(promise as { onCancel?: (fn: CancelHandler) => void }).onCancel = (fn) => + promiseState[0].add(fn) } -const isPromiseLike = (x: unknown): x is PromiseLike => +const isPromiseLike = ( + x: unknown, +): x is PromiseLike & { onCancel?: (fn: CancelHandler) => void } => typeof (x as any)?.then === 'function' /** @@ -160,9 +155,9 @@ const addDependency = ( throw new Error('[Bug] atom cannot depend on itself') } atomState.d.set(a, aState.n) - const abortablePromise = getPendingPromise(atomState) - if (abortablePromise) { - addPendingPromiseToDependency(atom, abortablePromise, aState) + const cancelablePromise = getPendingPromise(atomState) + if (cancelablePromise) { + addPendingPromiseToDependency(atom, cancelablePromise, aState) } aState.m?.t.add(atom) if (pending) { @@ -275,7 +270,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { const prevValue = atomState.v const pendingPromise = getPendingPromise(atomState) if (isPromiseLike(valueOrPromise)) { - patchPromiseForAbortability(valueOrPromise) + patchPromiseForCancelability(valueOrPromise) for (const a of atomState.d.keys()) { addPendingPromiseToDependency( atom, @@ -292,7 +287,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { if (!hasPrevValue || !Object.is(prevValue, atomState.v)) { ++atomState.n if (pendingPromise) { - abortPromise(pendingPromise) + cancelPromise(pendingPromise) } } } @@ -390,7 +385,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { const valueOrPromise = atom.read(getter, options as never) setAtomStateValueOrPromise(atom, atomState, valueOrPromise) if (isPromiseLike(valueOrPromise)) { - registerAbort(valueOrPromise, () => controller?.abort()) + valueOrPromise.onCancel?.(() => controller?.abort()) const complete = () => { if (atomState.m) { const pending = createPending() @@ -423,10 +418,10 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { for (const a of atomState.m?.t || []) { dependents.set(a, getAtomState(a, atomState)) } - for (const atomWithPendingAbortablePromise of atomState.p) { + for (const atomWithPendingPromise of atomState.p) { dependents.set( - atomWithPendingAbortablePromise, - getAtomState(atomWithPendingAbortablePromise, atomState), + atomWithPendingPromise, + getAtomState(atomWithPendingPromise, atomState), ) } getPendingDependents(pending, atom)?.forEach((dependent) => { From 7b07fa9df46aa7302464a8f15dd0ef9540167c4c Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 26 Aug 2024 10:02:36 +0900 Subject: [PATCH 12/15] small refactor --- src/vanilla/store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 333177bdc0..6241bd2b1f 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -155,9 +155,9 @@ const addDependency = ( throw new Error('[Bug] atom cannot depend on itself') } atomState.d.set(a, aState.n) - const cancelablePromise = getPendingPromise(atomState) - if (cancelablePromise) { - addPendingPromiseToDependency(atom, cancelablePromise, aState) + const pendingPromise = getPendingPromise(atomState) + if (pendingPromise) { + addPendingPromiseToDependency(atom, pendingPromise, aState) } aState.m?.t.add(atom) if (pending) { From 71db5da150c6dcc262bbefae1ade1906450df240 Mon Sep 17 00:00:00 2001 From: daishi Date: Mon, 26 Aug 2024 11:59:04 +0900 Subject: [PATCH 13/15] refactor --- src/vanilla/store.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 6241bd2b1f..7e4581a803 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -28,8 +28,8 @@ type PromiseState = [cancelHandlers: Set, settled: boolean] const cancelablePromiseMap = new WeakMap, PromiseState>() -const isPromisePending = (promise: PromiseLike) => - !cancelablePromiseMap.get(promise)?.[1] +const isPendingPromise = (value: unknown): value is PromiseLike => + isPromiseLike(value) && !cancelablePromiseMap.get(value)?.[1] const cancelPromise = (promise: PromiseLike) => { const promiseState = cancelablePromiseMap.get(promise) @@ -118,14 +118,6 @@ const returnAtomValue = (atomState: AtomState): Value => { return atomState.v! } -const getPendingPromise = (atomState: AtomState) => { - const value: unknown = atomState.v - if (isPromiseLike(value) && isPromisePending(value)) { - return value - } - return null -} - const addPendingPromiseToDependency = ( atom: AnyAtom, promise: PromiseLike, @@ -155,9 +147,8 @@ const addDependency = ( throw new Error('[Bug] atom cannot depend on itself') } atomState.d.set(a, aState.n) - const pendingPromise = getPendingPromise(atomState) - if (pendingPromise) { - addPendingPromiseToDependency(atom, pendingPromise, aState) + if (isPendingPromise(atomState.v)) { + addPendingPromiseToDependency(atom, atomState.v, aState) } aState.m?.t.add(atom) if (pending) { @@ -268,7 +259,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { ) => { const hasPrevValue = 'v' in atomState const prevValue = atomState.v - const pendingPromise = getPendingPromise(atomState) + const pendingPromise = isPendingPromise(atomState.v) ? atomState.v : null if (isPromiseLike(valueOrPromise)) { patchPromiseForCancelability(valueOrPromise) for (const a of atomState.d.keys()) { @@ -543,7 +534,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { atom: AnyAtom, atomState: AtomState, ) => { - if (atomState.m && !getPendingPromise(atomState)) { + if (atomState.m && !isPendingPromise(atomState.v)) { for (const a of atomState.d.keys()) { if (!atomState.m.d.has(a)) { const aMounted = mountAtom(pending, a, getAtomState(a, atomState)) From 597d943a3d39a82d8aaa37544e2bfce32a691c8c Mon Sep 17 00:00:00 2001 From: daishi Date: Tue, 27 Aug 2024 09:09:04 +0900 Subject: [PATCH 14/15] minor refactor --- src/vanilla/store.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index 7e4581a803..e2c389f7dc 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -34,8 +34,8 @@ const isPendingPromise = (value: unknown): value is PromiseLike => const cancelPromise = (promise: PromiseLike) => { const promiseState = cancelablePromiseMap.get(promise) if (promiseState) { - promiseState[0].forEach((fn) => fn()) promiseState[1] = true + promiseState[0].forEach((fn) => fn()) } else if (import.meta.env?.MODE !== 'production') { throw new Error('[Bug] cancelable promise not found') } @@ -49,11 +49,12 @@ const patchPromiseForCancelability = (promise: PromiseLike) => { const promiseState: PromiseState = [new Set(), false] cancelablePromiseMap.set(promise, promiseState) const settle = () => { - promiseState![1] = true + promiseState[1] = true } promise.then(settle, settle) - ;(promise as { onCancel?: (fn: CancelHandler) => void }).onCancel = (fn) => + ;(promise as { onCancel?: (fn: CancelHandler) => void }).onCancel = (fn) => { promiseState[0].add(fn) + } } const isPromiseLike = ( From ba0a731aff4e49726dc89fccb316b39b7ec5da5a Mon Sep 17 00:00:00 2001 From: daishi Date: Sun, 8 Sep 2024 13:53:32 +0900 Subject: [PATCH 15/15] improvement: cancel handler receives next value --- src/react/useAtomValue.ts | 31 +++++++++++++------------------ src/vanilla/store.ts | 8 ++++---- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/react/useAtomValue.ts b/src/react/useAtomValue.ts index d207187116..72c9d6498e 100644 --- a/src/react/useAtomValue.ts +++ b/src/react/useAtomValue.ts @@ -56,10 +56,7 @@ const continuablePromiseMap = new WeakMap< Promise >() -const createContinuablePromise = ( - promise: PromiseLike, - getLatest: () => PromiseLike | T, -) => { +const createContinuablePromise = (promise: PromiseLike) => { let continuablePromise = continuablePromiseMap.get(promise) if (!continuablePromise) { continuablePromise = new Promise((resolve, reject) => { @@ -76,18 +73,18 @@ const createContinuablePromise = ( } const registerCancelHandler = (p: PromiseLike) => { if ('onCancel' in p && typeof p.onCancel === 'function') { - p.onCancel(() => { - const nextValue = getLatest() - const nextPromise = isPromiseLike(nextValue) - ? nextValue - : Promise.resolve(nextValue) - if (import.meta.env?.MODE !== 'production' && nextPromise === p) { + p.onCancel((nextValue: PromiseLike | T) => { + if (import.meta.env?.MODE !== 'production' && nextValue === p) { throw new Error('[Bug] p is not updated even after cancelation') } - continuablePromiseMap.set(nextPromise, continuablePromise!) - curr = nextPromise - nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) - registerCancelHandler(nextPromise) + if (isPromiseLike(nextValue)) { + continuablePromiseMap.set(nextValue, continuablePromise!) + curr = nextValue + nextValue.then(onFulfilled(nextValue), onRejected(nextValue)) + registerCancelHandler(nextValue) + } else { + resolve(nextValue) + } }) } } @@ -148,9 +145,7 @@ export function useAtomValue(atom: Atom, options?: Options) { if (typeof delay === 'number') { const value = store.get(atom) if (isPromiseLike(value)) { - attachPromiseMeta( - createContinuablePromise(value, () => store.get(atom)), - ) + attachPromiseMeta(createContinuablePromise(value)) } // delay rerendering to wait a promise possibly to resolve setTimeout(rerender, delay) @@ -166,7 +161,7 @@ export function useAtomValue(atom: Atom, options?: Options) { // The use of isPromiseLike is to be consistent with `use` type. // `instanceof Promise` actually works fine in this case. if (isPromiseLike(value)) { - const promise = createContinuablePromise(value, () => store.get(atom)) + const promise = createContinuablePromise(value) return use(promise) } return value as Awaited diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index e2c389f7dc..07bbad31f4 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -23,7 +23,7 @@ const isActuallyWritableAtom = (atom: AnyAtom): atom is AnyWritableAtom => // Cancelable Promise // -type CancelHandler = () => void +type CancelHandler = (nextValue: unknown) => void type PromiseState = [cancelHandlers: Set, settled: boolean] const cancelablePromiseMap = new WeakMap, PromiseState>() @@ -31,11 +31,11 @@ const cancelablePromiseMap = new WeakMap, PromiseState>() const isPendingPromise = (value: unknown): value is PromiseLike => isPromiseLike(value) && !cancelablePromiseMap.get(value)?.[1] -const cancelPromise = (promise: PromiseLike) => { +const cancelPromise = (promise: PromiseLike, nextValue: unknown) => { const promiseState = cancelablePromiseMap.get(promise) if (promiseState) { promiseState[1] = true - promiseState[0].forEach((fn) => fn()) + promiseState[0].forEach((fn) => fn(nextValue)) } else if (import.meta.env?.MODE !== 'production') { throw new Error('[Bug] cancelable promise not found') } @@ -279,7 +279,7 @@ const buildStore = (getAtomState: StoreArgs[0]): Store => { if (!hasPrevValue || !Object.is(prevValue, atomState.v)) { ++atomState.n if (pendingPromise) { - cancelPromise(pendingPromise) + cancelPromise(pendingPromise, valueOrPromise) } } }