Skip to content

Commit 9a22ab9

Browse files
authored
feat: withPending() and loadable() (#13)
* Initial withPending implementation, based on unwrap * Allow fallback of withPending() to depend on another atom. Reimplement loadable().
1 parent 7b50f16 commit 9a22ab9

File tree

10 files changed

+380
-10
lines changed

10 files changed

+380
-10
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"test": "pnpm run '/^test:.*/'",
88
"test:biome": "biome check --write",
99
"test:types": "pnpm run -r --parallel test:types",
10-
"test:spec": "vitest run"
10+
"test:spec": "vitest run",
11+
"vitest": "vitest"
1112
},
1213
"devDependencies": {
1314
"@biomejs/biome": "1.9.4",

packages/jotai-eager/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export { derive } from './derive.js';
22
export { eagerAtom, isEagerError } from './eagerAtom.js';
3+
export { loadable } from './loadable.ts';
34
export { soon } from './soon.js';
45
export { soonAll } from './soonAll.js';
6+
export { withPending } from './withPending.ts';

packages/jotai-eager/src/isPromise.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ type PromiseMeta<T> =
2020
const PENDING: PromiseMetaPending = { status: 'pending' } as const;
2121
const promiseMetaCache = new WeakMap<object, PromiseMeta<unknown>>();
2222

23-
export function isPromise<T>(value: Promise<T> | unknown): value is Promise<T> {
24-
return !!(value as Promise<T>)?.then;
23+
export function isPromiseLike<T>(
24+
value: PromiseLike<T> | unknown,
25+
): value is PromiseLike<T> {
26+
return typeof (value as PromiseLike<T>)?.then === 'function';
2527
}
2628

2729
export function getPromiseMeta<T>(
2830
promise: unknown | Promise<T>,
2931
): PromiseMeta<T> | undefined {
30-
if (!isPromise(promise)) {
32+
if (!isPromiseLike(promise)) {
3133
return undefined;
3234
}
3335

@@ -37,7 +39,7 @@ export function getPromiseMeta<T>(
3739
}
3840

3941
export function setPromiseMeta<T>(
40-
promise: Promise<T>,
42+
promise: PromiseLike<T>,
4143
meta: PromiseMeta<T>,
4244
): void {
4345
promiseMetaCache.set(promise, meta);
@@ -46,7 +48,7 @@ export function setPromiseMeta<T>(
4648
/**
4749
* If it's a non promise, or a fulfilled promise.
4850
*/
49-
export function isKnown<T>(value: Promise<T> | unknown): boolean {
51+
export function isKnown<T>(value: PromiseLike<T> | unknown): boolean {
5052
const meta = getPromiseMeta(value);
5153

5254
if (meta) {
@@ -60,10 +62,17 @@ export function isKnown<T>(value: Promise<T> | unknown): boolean {
6062
* NOTE: If `promiseOrValue` is a Promise, but is not fulfilled, then it's undefined behavior.
6163
* @returns `promiseOrValue` if it's not a Promise, the fulfilled value if it's a Promise.
6264
*/
63-
export function getFulfilledValue<T>(promiseOrValue: Promise<T> | unknown): T {
65+
export function getFulfilledValue<T>(
66+
promiseOrValue: PromiseLike<T> | unknown,
67+
): T {
6468
const meta = getPromiseMeta(promiseOrValue);
6569
if (meta) {
6670
return (meta as PromiseMetaFulfilled<T>).value;
6771
}
6872
return promiseOrValue as T;
6973
}
74+
75+
export function getRejectionReason(promise: PromiseLike<unknown>): unknown {
76+
const meta = getPromiseMeta(promise);
77+
return (meta as PromiseMetaRejected)?.reason;
78+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { atom } from 'jotai/vanilla';
2+
import type { Atom } from 'jotai/vanilla';
3+
import { withPending } from './withPending.ts';
4+
5+
const cache1 = new WeakMap();
6+
const memo1 = <T>(create: () => T, dep1: object): T =>
7+
(cache1.has(dep1) ? cache1 : cache1.set(dep1, create())).get(dep1);
8+
9+
export type Loadable<Value> =
10+
| { state: 'loading' }
11+
| { state: 'hasError'; error: unknown }
12+
| { state: 'hasData'; data: Awaited<Value> };
13+
14+
const Pending = Symbol('The loadable is pending');
15+
16+
export function loadable<Value>(anAtom: Atom<Value>): Atom<Loadable<Value>> {
17+
return memo1(() => {
18+
const atomWithPending = withPending(anAtom, (): typeof Pending => Pending);
19+
if (import.meta.env?.MODE !== 'production') {
20+
atomWithPending.debugPrivate = true;
21+
}
22+
23+
return atom((get) => {
24+
let value: Awaited<Value> | typeof Pending;
25+
26+
try {
27+
value = get(atomWithPending);
28+
} catch (error) {
29+
return { state: 'hasError', error };
30+
}
31+
32+
if (value === Pending) {
33+
return { state: 'loading' };
34+
}
35+
return { state: 'hasData', data: value };
36+
});
37+
}, anAtom);
38+
}

packages/jotai-eager/src/soonAll.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
getFulfilledValue,
33
isKnown,
4-
isPromise,
4+
isPromiseLike,
55
setPromiseMeta,
66
} from './isPromise.js';
77

@@ -28,7 +28,7 @@ export function soonAll<T extends readonly unknown[]>(values: T): SoonAll<T> {
2828
return Promise.all(values).then((fulfilledValues) => {
2929
fulfilledValues.map((fulfilled, idx) => {
3030
const promise = values[idx];
31-
if (isPromise(promise)) {
31+
if (isPromiseLike(promise)) {
3232
setPromiseMeta(promise, { status: 'fulfilled', value: fulfilled });
3333
}
3434
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare interface ImportMeta {
2+
env?: {
3+
MODE: string;
4+
};
5+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { atom } from 'jotai/vanilla';
2+
import type { Getter } from 'jotai/vanilla';
3+
import type { Atom, WritableAtom } from 'jotai/vanilla';
4+
import { getPromiseMeta, setPromiseMeta, isPromiseLike } from './isPromise.ts';
5+
6+
const defaultFallback = () => undefined;
7+
8+
export interface WithPendingContext<Value> {
9+
get: Getter;
10+
prev: Awaited<Value> | undefined;
11+
pending: PromiseLike<Awaited<Value>>;
12+
}
13+
14+
export function withPending<Value, Args extends unknown[], Result>(
15+
anAtom: WritableAtom<Value, Args, Result>,
16+
): WritableAtom<Awaited<Value> | undefined, Args, Result>;
17+
18+
export function withPending<
19+
Value,
20+
Args extends unknown[],
21+
Result,
22+
PendingValue,
23+
>(
24+
anAtom: WritableAtom<Value, Args, Result>,
25+
fallback: (ctx: WithPendingContext<Value>) => PendingValue,
26+
): WritableAtom<Awaited<Value> | PendingValue, Args, Result>;
27+
28+
export function withPending<Value>(
29+
anAtom: Atom<Value>,
30+
): Atom<Awaited<Value> | undefined>;
31+
32+
export function withPending<Value, PendingValue>(
33+
anAtom: Atom<Value>,
34+
fallback: (ctx: WithPendingContext<Value>) => PendingValue,
35+
): Atom<Awaited<Value> | PendingValue>;
36+
37+
export function withPending<
38+
Value,
39+
Args extends unknown[],
40+
Result,
41+
PendingValue,
42+
>(
43+
anAtom: WritableAtom<Value, Args, Result> | Atom<Value>,
44+
fallback: (
45+
ctx: WithPendingContext<Value>,
46+
) => PendingValue = defaultFallback as never,
47+
) {
48+
type PromiseAndValue = { readonly p?: PromiseLike<unknown> } & (
49+
| { readonly v: Awaited<Value> }
50+
| { readonly f: PendingValue; readonly v?: Awaited<Value> }
51+
);
52+
const refreshAtom = atom(0);
53+
54+
if (import.meta.env?.MODE !== 'production') {
55+
refreshAtom.debugPrivate = true;
56+
}
57+
58+
const promiseAndValueAtom: WritableAtom<PromiseAndValue, [], void> & {
59+
init?: undefined;
60+
} = atom(
61+
(get, { setSelf }) => {
62+
get(refreshAtom);
63+
const prev = get(promiseAndValueAtom) as PromiseAndValue | undefined;
64+
const promise = get(anAtom);
65+
if (!isPromiseLike(promise)) {
66+
return { v: promise as Awaited<Value> } as PromiseAndValue;
67+
}
68+
const meta = getPromiseMeta(promise);
69+
if (meta?.status === 'fulfilled') {
70+
return { p: promise, v: meta.value } as PromiseAndValue;
71+
}
72+
if (meta?.status === 'rejected') {
73+
throw meta.reason;
74+
}
75+
76+
if (promise !== prev?.p) {
77+
promise.then(
78+
(value) => {
79+
setPromiseMeta(promise, { status: 'fulfilled', value });
80+
setSelf();
81+
},
82+
(reason) => {
83+
setPromiseMeta(promise, { status: 'rejected', reason });
84+
setSelf();
85+
},
86+
);
87+
}
88+
89+
if (prev && 'v' in prev) {
90+
return {
91+
p: promise,
92+
f: fallback({
93+
get,
94+
prev: prev.v,
95+
pending: promise as PromiseLike<Awaited<Value>>,
96+
}),
97+
v: prev.v,
98+
} as PromiseAndValue;
99+
}
100+
return {
101+
p: promise,
102+
f: fallback({
103+
get,
104+
prev: undefined,
105+
pending: promise as PromiseLike<Awaited<Value>>,
106+
}),
107+
} as PromiseAndValue;
108+
},
109+
(_get, set) => {
110+
set(refreshAtom, (c) => c + 1);
111+
},
112+
);
113+
// HACK to read PromiseAndValue atom before initialization
114+
promiseAndValueAtom.init = undefined;
115+
116+
if (import.meta.env?.MODE !== 'production') {
117+
promiseAndValueAtom.debugPrivate = true;
118+
}
119+
120+
return atom(
121+
(get) => {
122+
const state = get(promiseAndValueAtom);
123+
if ('f' in state) {
124+
// is pending
125+
return state.f;
126+
}
127+
return state.v;
128+
},
129+
(_get, set, ...args) =>
130+
set(anAtom as WritableAtom<Value, unknown[], unknown>, ...args),
131+
);
132+
}

packages/jotai-eager/tests/eagerAtom.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Atom } from 'jotai';
1+
import type { Atom } from 'jotai/vanilla';
22
import { eagerAtom, isEagerError } from 'jotai-eager';
33
import { atom, createStore } from 'jotai/vanilla';
44
import { beforeEach, describe, expect, expectTypeOf, it } from 'vitest';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { atom, createStore } from 'jotai/vanilla';
3+
import { loadable } from 'jotai-eager';
4+
5+
describe('loadable', () => {
6+
it('should return fulfilled value of an already resolved async atom', async () => {
7+
const store = createStore();
8+
const asyncAtom = atom(Promise.resolve('concrete'));
9+
10+
expect(await store.get(asyncAtom)).toEqual('concrete');
11+
expect(store.get(loadable(asyncAtom))).toEqual({
12+
state: 'loading',
13+
});
14+
await Promise.resolve(); // wait for a tick
15+
expect(store.get(loadable(asyncAtom))).toEqual({
16+
state: 'hasData',
17+
data: 'concrete',
18+
});
19+
});
20+
21+
it('should get the latest loadable state after the promise resolves', async () => {
22+
const store = createStore();
23+
const asyncAtom = atom(Promise.resolve());
24+
const loadableAtom = loadable(asyncAtom);
25+
26+
expect(store.get(loadableAtom)).toHaveProperty('state', 'loading');
27+
28+
await store.get(asyncAtom);
29+
30+
expect(store.get(loadableAtom)).toHaveProperty('state', 'hasData');
31+
});
32+
});

0 commit comments

Comments
 (0)