atomirx 0.0.2 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +868 -161
  2. package/coverage/src/core/onCreateHook.ts.html +72 -70
  3. package/dist/core/atom.d.ts +83 -6
  4. package/dist/core/batch.d.ts +3 -3
  5. package/dist/core/derived.d.ts +69 -22
  6. package/dist/core/effect.d.ts +52 -52
  7. package/dist/core/getAtomState.d.ts +29 -0
  8. package/dist/core/hook.d.ts +1 -1
  9. package/dist/core/onCreateHook.d.ts +37 -23
  10. package/dist/core/onErrorHook.d.ts +49 -0
  11. package/dist/core/promiseCache.d.ts +23 -32
  12. package/dist/core/select.d.ts +208 -29
  13. package/dist/core/types.d.ts +107 -22
  14. package/dist/core/withReady.d.ts +115 -0
  15. package/dist/core/withReady.test.d.ts +1 -0
  16. package/dist/index-CBVj1kSj.js +1350 -0
  17. package/dist/index-Cxk9v0um.cjs +1 -0
  18. package/dist/index.cjs +1 -1
  19. package/dist/index.d.ts +12 -8
  20. package/dist/index.js +18 -15
  21. package/dist/react/index.cjs +10 -10
  22. package/dist/react/index.d.ts +2 -1
  23. package/dist/react/index.js +422 -377
  24. package/dist/react/rx.d.ts +114 -25
  25. package/dist/react/useAction.d.ts +5 -4
  26. package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
  27. package/dist/react/useSelector.test.d.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/core/atom.test.ts +307 -43
  30. package/src/core/atom.ts +144 -22
  31. package/src/core/batch.test.ts +10 -10
  32. package/src/core/batch.ts +3 -3
  33. package/src/core/define.test.ts +12 -11
  34. package/src/core/define.ts +1 -1
  35. package/src/core/derived.test.ts +906 -72
  36. package/src/core/derived.ts +192 -81
  37. package/src/core/effect.test.ts +651 -45
  38. package/src/core/effect.ts +102 -98
  39. package/src/core/getAtomState.ts +69 -0
  40. package/src/core/hook.test.ts +5 -5
  41. package/src/core/hook.ts +1 -1
  42. package/src/core/onCreateHook.ts +38 -23
  43. package/src/core/onErrorHook.test.ts +350 -0
  44. package/src/core/onErrorHook.ts +52 -0
  45. package/src/core/promiseCache.test.ts +5 -3
  46. package/src/core/promiseCache.ts +76 -71
  47. package/src/core/select.ts +405 -130
  48. package/src/core/selector.test.ts +574 -32
  49. package/src/core/types.ts +107 -29
  50. package/src/core/withReady.test.ts +534 -0
  51. package/src/core/withReady.ts +191 -0
  52. package/src/core/withUse.ts +1 -1
  53. package/src/index.test.ts +4 -4
  54. package/src/index.ts +21 -7
  55. package/src/react/index.ts +2 -1
  56. package/src/react/rx.test.tsx +173 -18
  57. package/src/react/rx.tsx +274 -43
  58. package/src/react/useAction.test.ts +12 -14
  59. package/src/react/useAction.ts +11 -9
  60. package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
  61. package/src/react/{useValue.ts → useSelector.ts} +64 -33
  62. package/v2.md +44 -44
  63. package/dist/index-2ok7ilik.js +0 -1217
  64. package/dist/index-B_5SFzfl.cjs +0 -1
  65. /package/dist/{react/useValue.test.d.ts → core/onErrorHook.test.d.ts} +0 -0
@@ -0,0 +1,191 @@
1
+ import { isPromiseLike } from "./isPromiseLike";
2
+ import { trackPromise } from "./promiseCache";
3
+ import { SelectContext } from "./select";
4
+ import { AnyFunc, Atom } from "./types";
5
+
6
+ /**
7
+ * Extension interface that adds `ready()` method to SelectContext.
8
+ * Used in derived atoms and effects to wait for non-null values.
9
+ */
10
+ export interface WithReadySelectContext {
11
+ /**
12
+ * Wait for an atom to have a non-null/non-undefined value.
13
+ *
14
+ * If the value is null/undefined, the computation suspends until the atom
15
+ * changes to a non-null value, then automatically resumes.
16
+ *
17
+ * **IMPORTANT: Only use in `derived()` or `effect()` context**
18
+ *
19
+ * @param atom - The atom to read and wait for
20
+ * @returns The non-null value (type excludes null | undefined)
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // Wait for currentArticleId to be set before computing
25
+ * const currentArticle$ = derived(({ ready, read }) => {
26
+ * const id = ready(currentArticleId$); // Suspends if null
27
+ * const cache = read(articleCache$);
28
+ * return cache[id];
29
+ * });
30
+ * ```
31
+ */
32
+ ready<T>(
33
+ atom: Atom<T>
34
+ ): T extends PromiseLike<any> ? never : Exclude<T, null | undefined>;
35
+
36
+ /**
37
+ * Wait for a selected value from an atom to be non-null/non-undefined.
38
+ *
39
+ * If the selected value is null/undefined, the computation suspends until the
40
+ * selected value changes to a non-null value, then automatically resumes.
41
+ *
42
+ * **IMPORTANT: Only use in `derived()` or `effect()` context**
43
+ *
44
+ * @param atom - The atom to read
45
+ * @param selector - Function to extract/transform the value
46
+ * @returns The non-null selected value
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * // Wait for user's email to be set
51
+ * const emailDerived$ = derived(({ ready }) => {
52
+ * const email = ready(user$, u => u.email); // Suspends if email is null
53
+ * return `Contact: ${email}`;
54
+ * });
55
+ * ```
56
+ */
57
+ ready<T, R>(
58
+ atom: Atom<T>,
59
+ selector: (current: Awaited<T>) => R
60
+ ): R extends PromiseLike<any> ? never : Exclude<R, null | undefined>;
61
+
62
+ /**
63
+ * Execute a function and wait for its result to be non-null/non-undefined.
64
+ *
65
+ * If the function returns null/undefined, the computation suspends until
66
+ * re-executed with a non-null result.
67
+ *
68
+ * **IMPORTANT: Only use in `derived()` or `effect()` context**
69
+ *
70
+ * **NOTE:** This overload is designed for use with async combinators like
71
+ * `all()`, `race()`, `any()`, `settled()` where promises come from stable
72
+ * atom sources. It does NOT support dynamic promise creation (returning a
73
+ * new Promise from the callback). For async selectors that return promises,
74
+ * use `ready(atom$, selector?)` instead.
75
+ *
76
+ * @param fn - Synchronous function to execute and wait for
77
+ * @returns The non-null result (excludes null | undefined)
78
+ * @throws {Error} If the callback returns a Promise
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * // Wait for a computed value to be ready
83
+ * const result$ = derived(({ ready, read }) => {
84
+ * const value = ready(() => computeExpensiveValue(read(input$)));
85
+ * return `Result: ${value}`;
86
+ * });
87
+ * ```
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * // Use with async combinators (all, race, any, settled)
92
+ * const combined$ = derived(({ ready, all }) => {
93
+ * const [user, posts] = ready(() => all(user$, posts$));
94
+ * return { user, posts };
95
+ * });
96
+ * ```
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * // For async selectors, use ready(atom$, selector?) instead:
101
+ * const data$ = derived(({ ready }) => {
102
+ * const data = ready(source$, (val) => fetchData(val.id));
103
+ * return data;
104
+ * });
105
+ * ```
106
+ */
107
+ ready<T>(
108
+ fn: () => T
109
+ ): T extends PromiseLike<any> ? never : Exclude<Awaited<T>, null | undefined>;
110
+ }
111
+
112
+ /**
113
+ * Internal helper that suspends computation if value is null/undefined.
114
+ */
115
+ function waitForValue<T>(value: T): any {
116
+ if (value === undefined || value === null) {
117
+ throw new Promise(() => {});
118
+ }
119
+
120
+ // Handle async selectors: when the selector returns a Promise,
121
+ // we track its state and handle suspension/resolution accordingly
122
+ if (isPromiseLike(value)) {
123
+ const p = trackPromise(value);
124
+
125
+ // Promise is still pending - suspend computation by throwing
126
+ // the tracked promise. This enables React Suspense integration.
127
+ if (p.status === "pending") {
128
+ throw p.promise;
129
+ }
130
+
131
+ // Promise resolved successfully - return the resolved value.
132
+ // Note: This bypasses null/undefined checking for async results,
133
+ // allowing async selectors to return any value including null.
134
+ if (p.status === "fulfilled") {
135
+ return p.value;
136
+ }
137
+
138
+ // Promise rejected - propagate the error
139
+ throw p.error;
140
+ }
141
+
142
+ // For sync values (no selector, or selector returned sync value),
143
+ // check for null/undefined and suspend if not ready
144
+
145
+ return value as Exclude<T, null | undefined>;
146
+ }
147
+
148
+ /**
149
+ * Plugin that adds `ready()` method to a SelectContext.
150
+ *
151
+ * `ready()` enables a "reactive suspension" pattern where derived atoms
152
+ * wait for required values before computing. This is useful for:
153
+ *
154
+ * - Route-based entity loading (`/article/:id` - wait for ID to be set)
155
+ * - Authentication-gated content (wait for user to be logged in)
156
+ * - Conditional data dependencies (wait for prerequisite data)
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * // Used internally by derived() - you don't need to call this directly
161
+ * const result = select((context) => fn(context.use(withReady())));
162
+ * ```
163
+ */
164
+ export function withReady() {
165
+ return <TContext extends SelectContext>(
166
+ context: TContext
167
+ ): TContext & WithReadySelectContext => {
168
+ return {
169
+ ...context,
170
+ ready: (
171
+ atomOrFn: Atom<any> | AnyFunc,
172
+ selector?: (current: any) => any
173
+ ): any => {
174
+ if (typeof atomOrFn === "function") {
175
+ const value = atomOrFn();
176
+ if (isPromiseLike(value)) {
177
+ throw new Error(
178
+ "ready(callback) overload does not support async callbacks. Use ready(atom, selector?) instead."
179
+ );
180
+ }
181
+ return waitForValue(value);
182
+ }
183
+ const value = context.read(atomOrFn);
184
+ // we allow selector to return a promise, and wait for that promise if it is not resolved yet
185
+ const selected = selector ? selector(value) : value;
186
+
187
+ return waitForValue(selected);
188
+ },
189
+ };
190
+ };
191
+ }
@@ -38,7 +38,7 @@ import type { Pipeable } from "./types";
38
38
  */
39
39
  export function withUse<TSource extends object>(source: TSource) {
40
40
  return Object.assign(source, {
41
- use<TNew = void>(plugin: (source: TSource) => TNew): any {
41
+ use<TNew = void>(plugin: (source: NoInfer<TSource>) => TNew): any {
42
42
  const result = plugin(source);
43
43
  // Void/falsy: return original source (side-effect only plugins)
44
44
  if (!result) return source;
package/src/index.test.ts CHANGED
@@ -17,7 +17,7 @@ describe("atomirx exports", () => {
17
17
  it("should export atom", () => {
18
18
  expect(typeof atom).toBe("function");
19
19
  const count = atom(0);
20
- expect(count.value).toBe(0);
20
+ expect(count.get()).toBe(0);
21
21
  });
22
22
 
23
23
  it("should export batch", () => {
@@ -31,8 +31,8 @@ describe("atomirx exports", () => {
31
31
  it("should export derived", async () => {
32
32
  expect(typeof derived).toBe("function");
33
33
  const count = atom(5);
34
- const doubled = derived(({ get }) => get(count) * 2);
35
- expect(await doubled.value).toBe(10);
34
+ const doubled = derived(({ read }) => read(count) * 2);
35
+ expect(await doubled.get()).toBe(10);
36
36
  });
37
37
 
38
38
  it("should export effect", () => {
@@ -53,7 +53,7 @@ describe("atomirx exports", () => {
53
53
  it("should export isDerived", () => {
54
54
  expect(typeof isDerived).toBe("function");
55
55
  const count = atom(0);
56
- const doubled = derived(({ get }) => get(count) * 2);
56
+ const doubled = derived(({ read }) => read(count) * 2);
57
57
  expect(isDerived(count)).toBe(false);
58
58
  expect(isDerived(doubled)).toBe(true);
59
59
  });
package/src/index.ts CHANGED
@@ -1,16 +1,16 @@
1
1
  // Core
2
- export { atom } from "./core/atom";
2
+ export { atom, readonly } from "./core/atom";
3
3
  export { batch } from "./core/batch";
4
4
  export { define } from "./core/define";
5
- export { derived } from "./core/derived";
6
- export { effect } from "./core/effect";
5
+ export { derived, type DerivedContext } from "./core/derived";
6
+ export { effect, type EffectContext } from "./core/effect";
7
7
  export { emitter } from "./core/emitter";
8
8
  export { isAtom, isDerived } from "./core/isAtom";
9
9
  export { select, AllAtomsRejectedError } from "./core/select";
10
10
 
11
11
  // Promise utilities
12
+ export { getAtomState } from "./core/getAtomState";
12
13
  export {
13
- getAtomState,
14
14
  isPending,
15
15
  isFulfilled,
16
16
  isRejected,
@@ -33,19 +33,33 @@ export type {
33
33
  Equality,
34
34
  EqualityShorthand,
35
35
  Getter,
36
+ KeyedResult,
36
37
  MutableAtom,
37
38
  MutableAtomMeta,
38
39
  Pipeable,
40
+ SelectStateResult,
39
41
  SettledResult,
40
42
  } from "./core/types";
41
43
 
42
44
  export { onCreateHook } from "./core/onCreateHook";
43
- export type { AtomCreateInfo, ModuleCreateInfo } from "./core/onCreateHook";
45
+ export type {
46
+ CreateInfo,
47
+ MutableInfo,
48
+ DerivedInfo,
49
+ EffectInfo,
50
+ ModuleInfo,
51
+ } from "./core/onCreateHook";
52
+
53
+ export { onErrorHook } from "./core/onErrorHook";
54
+ export type { ErrorInfo } from "./core/onErrorHook";
44
55
 
45
56
  export type {
46
57
  SelectContext,
47
58
  SelectResult,
48
- ContextSelectorFn,
59
+ ReactiveSelector as ContextSelectorFn,
60
+ SafeResult,
49
61
  } from "./core/select";
50
62
 
51
- export type { PromiseState } from "./core/promiseCache";
63
+ export type { PromiseState, CombinedPromiseMeta } from "./core/promiseCache";
64
+
65
+ export { promisesEqual } from "./core/promiseCache";
@@ -1,8 +1,9 @@
1
- export { useValue } from "./useValue";
1
+ export { useSelector } from "./useSelector";
2
2
  export { useStable } from "./useStable";
3
3
  export type { UseStableResult } from "./useStable";
4
4
  export { useAction } from "./useAction";
5
5
  export { rx } from "./rx";
6
+ export type { RxOptions } from "./rx";
6
7
 
7
8
  export type {
8
9
  ActionState,
@@ -24,7 +24,9 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
24
24
  it("should render derived value with context selector", () => {
25
25
  const count = atom(5);
26
26
 
27
- render(<div data-testid="result">{rx(({ get }) => get(count) * 2)}</div>);
27
+ render(
28
+ <div data-testid="result">{rx(({ read }) => read(count) * 2)}</div>
29
+ );
28
30
 
29
31
  expect(screen.getByTestId("result").textContent).toBe("10");
30
32
  });
@@ -61,7 +63,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
61
63
 
62
64
  render(
63
65
  <div data-testid="result">
64
- {rx(({ get }) => `${get(firstName)} ${get(lastName)}`)}
66
+ {rx(({ read }) => `${read(firstName)} ${read(lastName)}`)}
65
67
  </div>
66
68
  );
67
69
 
@@ -75,7 +77,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
75
77
 
76
78
  render(
77
79
  <div data-testid="result">
78
- {rx(({ get }) => get(a) + get(b) + get(c))}
80
+ {rx(({ read }) => read(a) + read(b) + read(c))}
79
81
  </div>
80
82
  );
81
83
 
@@ -101,7 +103,9 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
101
103
  it("should update when source atom changes (context selector)", () => {
102
104
  const count = atom(5);
103
105
 
104
- render(<div data-testid="result">{rx(({ get }) => get(count) * 2)}</div>);
106
+ render(
107
+ <div data-testid="result">{rx(({ read }) => read(count) * 2)}</div>
108
+ );
105
109
 
106
110
  expect(screen.getByTestId("result").textContent).toBe("10");
107
111
 
@@ -117,7 +121,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
117
121
  const b = atom(2);
118
122
 
119
123
  render(
120
- <div data-testid="result">{rx(({ get }) => get(a) + get(b))}</div>
124
+ <div data-testid="result">{rx(({ read }) => read(a) + read(b))}</div>
121
125
  );
122
126
 
123
127
  expect(screen.getByTestId("result").textContent).toBe("3");
@@ -142,8 +146,8 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
142
146
  const a = atom(1);
143
147
  const b = atom(2);
144
148
 
145
- const selectorFn = vi.fn(({ get }: SelectContext) =>
146
- get(flag) ? get(a) : get(b)
149
+ const selectorFn = vi.fn(({ read }: SelectContext) =>
150
+ read(flag) ? read(a) : read(b)
147
151
  );
148
152
 
149
153
  render(<div data-testid="result">{rx(selectorFn)}</div>);
@@ -168,7 +172,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
168
172
 
169
173
  render(
170
174
  <div data-testid="result">
171
- {rx(({ get }) => (get(flag) ? get(a) : get(b)))}
175
+ {rx(({ read }) => (read(flag) ? read(a) : read(b)))}
172
176
  </div>
173
177
  );
174
178
 
@@ -199,7 +203,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
199
203
  renderCount.current++;
200
204
  return (
201
205
  <div data-testid="result">
202
- {rx(({ get }) => JSON.stringify(get(user)))}
206
+ {rx(({ read }) => JSON.stringify(read(user)))}
203
207
  </div>
204
208
  );
205
209
  };
@@ -223,9 +227,9 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
223
227
  const user = atom({ name: "John", age: 30 });
224
228
  const selectorCallCount = { current: 0 };
225
229
 
226
- const selector = ({ get }: SelectContext) => {
230
+ const selector = ({ read }: SelectContext) => {
227
231
  selectorCallCount.current++;
228
- return JSON.stringify(get(user));
232
+ return JSON.stringify(read(user));
229
233
  };
230
234
 
231
235
  render(<div data-testid="result">{rx(selector, "strict")}</div>);
@@ -254,7 +258,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
254
258
  return (
255
259
  <div data-testid="result">
256
260
  {rx(
257
- ({ get }) => JSON.stringify(get(user)),
261
+ ({ read }) => JSON.stringify(read(user)),
258
262
  (a, b) => a === b // Compare stringified values
259
263
  )}
260
264
  </div>
@@ -285,7 +289,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
285
289
  const Parent = () => {
286
290
  parentRenderCount.current++;
287
291
  return (
288
- <div data-testid="result">{rx(({ get }) => get(count) * 2)}</div>
292
+ <div data-testid="result">{rx(({ read }) => read(count) * 2)}</div>
289
293
  );
290
294
  };
291
295
 
@@ -317,7 +321,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
317
321
  const flag = atom(false);
318
322
 
319
323
  render(
320
- <div data-testid="result">{rx(({ get }) => String(get(flag)))}</div>
324
+ <div data-testid="result">{rx(({ read }) => String(read(flag)))}</div>
321
325
  );
322
326
 
323
327
  expect(screen.getByTestId("result").textContent).toBe("false");
@@ -352,9 +356,9 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
352
356
 
353
357
  render(
354
358
  <div data-testid="result">
355
- {rx(({ get }) => {
359
+ {rx(({ read }) => {
356
360
  try {
357
- return get(asyncAtom) * 2;
361
+ return read(asyncAtom) * 2;
358
362
  } catch {
359
363
  return "loading";
360
364
  }
@@ -372,7 +376,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
372
376
  const sourceAtom = atom(5);
373
377
 
374
378
  render(
375
- <div data-testid="result">{rx(({ get }) => get(sourceAtom) * 2)}</div>
379
+ <div data-testid="result">{rx(({ read }) => read(sourceAtom) * 2)}</div>
376
380
  );
377
381
 
378
382
  expect(screen.getByTestId("result").textContent).toBe("10");
@@ -396,7 +400,7 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
396
400
  render(
397
401
  <div data-testid="result">
398
402
  {rx(({ all }) => {
399
- const [valA, valB] = all(a, b);
403
+ const [valA, valB] = all([a, b]);
400
404
  return valA + valB;
401
405
  })}
402
406
  </div>
@@ -413,4 +417,155 @@ describe.each(wrappers)("rx - $mode", ({ render }) => {
413
417
  expect(screen.getByTestId("result").textContent).toBe("12");
414
418
  });
415
419
  });
420
+
421
+ describe("loading/error options", () => {
422
+ it("should render loading fallback when atom is pending", () => {
423
+ const asyncAtom = atom(new Promise<string>(() => {}));
424
+
425
+ render(
426
+ <div data-testid="result">
427
+ {rx(asyncAtom, { loading: () => <span>Loading...</span> })}
428
+ </div>
429
+ );
430
+
431
+ expect(screen.getByTestId("result").textContent).toBe("Loading...");
432
+ });
433
+
434
+ it("should render error fallback when atom has error", async () => {
435
+ const error = new Error("Test error");
436
+ const rejectedPromise = Promise.reject(error);
437
+ rejectedPromise.catch(() => {}); // Prevent unhandled rejection
438
+ const asyncAtom = atom(rejectedPromise);
439
+
440
+ // Wait for promise to be tracked
441
+ await act(async () => {
442
+ await Promise.resolve();
443
+ await Promise.resolve();
444
+ });
445
+
446
+ render(
447
+ <div data-testid="result">
448
+ {rx(asyncAtom, {
449
+ error: ({ error: e }) => <span>Error: {(e as Error).message}</span>,
450
+ })}
451
+ </div>
452
+ );
453
+
454
+ expect(screen.getByTestId("result").textContent).toBe(
455
+ "Error: Test error"
456
+ );
457
+ });
458
+
459
+ it("should render value when atom resolves with loading option", async () => {
460
+ let resolve: (value: string) => void;
461
+ const promise = new Promise<string>((r) => {
462
+ resolve = r;
463
+ });
464
+ const asyncAtom = atom(promise);
465
+
466
+ const { rerender } = render(
467
+ <div data-testid="result">
468
+ {rx(asyncAtom, {
469
+ loading: () => <span>Loading...</span>,
470
+ })}
471
+ </div>
472
+ );
473
+
474
+ expect(screen.getByTestId("result").textContent).toBe("Loading...");
475
+
476
+ await act(async () => {
477
+ resolve!("Hello");
478
+ await Promise.resolve();
479
+ await Promise.resolve();
480
+ });
481
+
482
+ rerender(
483
+ <div data-testid="result">
484
+ {rx(asyncAtom, {
485
+ loading: () => <span>Loading...</span>,
486
+ })}
487
+ </div>
488
+ );
489
+
490
+ expect(screen.getByTestId("result").textContent).toBe("Hello");
491
+ });
492
+
493
+ it("should work with selector function and loading option", () => {
494
+ const asyncAtom = atom(new Promise<number>(() => {}));
495
+
496
+ render(
497
+ <div data-testid="result">
498
+ {rx(({ read }) => read(asyncAtom) * 2, {
499
+ loading: () => <span>Computing...</span>,
500
+ })}
501
+ </div>
502
+ );
503
+
504
+ expect(screen.getByTestId("result").textContent).toBe("Computing...");
505
+ });
506
+
507
+ it("should support both loading and error options", async () => {
508
+ const error = new Error("Failed");
509
+ const rejectedPromise = Promise.reject(error);
510
+ rejectedPromise.catch(() => {});
511
+ const asyncAtom = atom(rejectedPromise);
512
+
513
+ await act(async () => {
514
+ await Promise.resolve();
515
+ await Promise.resolve();
516
+ });
517
+
518
+ render(
519
+ <div data-testid="result">
520
+ {rx(asyncAtom, {
521
+ loading: () => <span>Loading...</span>,
522
+ error: ({ error: e }) => (
523
+ <span>Failed: {(e as Error).message}</span>
524
+ ),
525
+ })}
526
+ </div>
527
+ );
528
+
529
+ expect(screen.getByTestId("result").textContent).toBe("Failed: Failed");
530
+ });
531
+
532
+ it("should pass equality in options object", () => {
533
+ const user = atom({ id: 1, name: "John" });
534
+ const renderSpy = vi.fn();
535
+
536
+ function TestComponent() {
537
+ renderSpy();
538
+ return (
539
+ <div data-testid="result">
540
+ {rx(({ read }) => read(user).name, {
541
+ equals: (a, b) => a === b,
542
+ })}
543
+ </div>
544
+ );
545
+ }
546
+
547
+ render(<TestComponent />);
548
+ expect(screen.getByTestId("result").textContent).toBe("John");
549
+
550
+ // Update with same name
551
+ act(() => {
552
+ user.set({ id: 2, name: "John" });
553
+ });
554
+
555
+ // Name didn't change, so rx content should be same
556
+ expect(screen.getByTestId("result").textContent).toBe("John");
557
+ });
558
+
559
+ it("should still work with legacy equality parameter", () => {
560
+ const count = atom(5);
561
+
562
+ render(
563
+ <div data-testid="result">
564
+ {rx(({ read }) => read(count) * 2, "strict")}
565
+ </div>
566
+ );
567
+
568
+ expect(screen.getByTestId("result").textContent).toBe("10");
569
+ });
570
+ });
416
571
  });