atomirx 0.0.1 → 0.0.4
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.
- package/README.md +867 -160
- package/dist/core/atom.d.ts +83 -6
- package/dist/core/batch.d.ts +3 -3
- package/dist/core/derived.d.ts +55 -21
- package/dist/core/effect.d.ts +47 -51
- package/dist/core/getAtomState.d.ts +29 -0
- package/dist/core/promiseCache.d.ts +23 -32
- package/dist/core/select.d.ts +208 -29
- package/dist/core/types.d.ts +55 -19
- package/dist/core/withReady.d.ts +69 -0
- package/dist/index-CqO6BDwj.cjs +1 -0
- package/dist/index-D8RDOTB_.js +1319 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.js +12 -10
- package/dist/react/index.cjs +10 -10
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +423 -379
- package/dist/react/rx.d.ts +114 -25
- package/dist/react/useAction.d.ts +5 -4
- package/dist/react/{useValue.d.ts → useSelector.d.ts} +56 -25
- package/dist/react/useSelector.test.d.ts +1 -0
- package/package.json +17 -1
- package/src/core/atom.test.ts +307 -43
- package/src/core/atom.ts +143 -21
- package/src/core/batch.test.ts +10 -10
- package/src/core/batch.ts +3 -3
- package/src/core/derived.test.ts +727 -72
- package/src/core/derived.ts +141 -73
- package/src/core/effect.test.ts +259 -39
- package/src/core/effect.ts +62 -85
- package/src/core/getAtomState.ts +69 -0
- package/src/core/promiseCache.test.ts +5 -3
- package/src/core/promiseCache.ts +76 -71
- package/src/core/select.ts +405 -130
- package/src/core/selector.test.ts +574 -32
- package/src/core/types.ts +54 -26
- package/src/core/withReady.test.ts +360 -0
- package/src/core/withReady.ts +127 -0
- package/src/core/withUse.ts +1 -1
- package/src/index.test.ts +4 -4
- package/src/index.ts +11 -6
- package/src/react/index.ts +2 -1
- package/src/react/rx.test.tsx +173 -18
- package/src/react/rx.tsx +274 -43
- package/src/react/useAction.test.ts +12 -14
- package/src/react/useAction.ts +11 -9
- package/src/react/{useValue.test.ts → useSelector.test.ts} +16 -16
- package/src/react/{useValue.ts → useSelector.ts} +64 -33
- package/v2.md +44 -44
- package/dist/index-2ok7ilik.js +0 -1217
- package/dist/index-B_5SFzfl.cjs +0 -1
- /package/dist/{react/useValue.test.d.ts → core/withReady.test.d.ts} +0 -0
package/src/react/rx.tsx
CHANGED
|
@@ -1,14 +1,58 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
memo,
|
|
4
|
+
ReactElement,
|
|
5
|
+
ReactNode,
|
|
6
|
+
Suspense,
|
|
7
|
+
ErrorInfo,
|
|
8
|
+
useCallback,
|
|
9
|
+
useRef,
|
|
10
|
+
} from "react";
|
|
2
11
|
import { Atom, Equality } from "../core/types";
|
|
3
|
-
import {
|
|
12
|
+
import { useSelector } from "./useSelector";
|
|
4
13
|
import { shallowEqual } from "../core/equality";
|
|
5
14
|
import { isAtom } from "../core/isAtom";
|
|
6
|
-
import {
|
|
15
|
+
import { ReactiveSelector, SelectContext } from "../core/select";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for rx() with inline loading/error handling and memoization control.
|
|
19
|
+
*/
|
|
20
|
+
export interface RxOptions<T> {
|
|
21
|
+
/** Equality function for value comparison */
|
|
22
|
+
equals?: Equality<T>;
|
|
23
|
+
/** Render function for loading state */
|
|
24
|
+
loading?: () => ReactNode;
|
|
25
|
+
/** Render function for error state */
|
|
26
|
+
error?: (props: { error: unknown }) => ReactNode;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Dependencies array for selector memoization.
|
|
30
|
+
*
|
|
31
|
+
* Controls when the selector callback is recreated:
|
|
32
|
+
* - **Atom shorthand** (`rx(atom$)`): Always memoized by atom reference (deps ignored)
|
|
33
|
+
* - **Function selector without deps**: No memoization (recreated every render)
|
|
34
|
+
* - **Function selector with `deps: []`**: Stable forever (never recreated)
|
|
35
|
+
* - **Function selector with `deps: [a, b]`**: Recreated when deps change
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* // No memoization (default for functions) - selector recreated every render
|
|
40
|
+
* rx(({ read }) => read(count$) * 2)
|
|
41
|
+
*
|
|
42
|
+
* // Stable selector - never recreated
|
|
43
|
+
* rx(({ read }) => read(count$) * 2, { deps: [] })
|
|
44
|
+
*
|
|
45
|
+
* // Recreate when multiplier changes
|
|
46
|
+
* rx(({ read }) => read(count$) * multiplier, { deps: [multiplier] })
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
deps?: unknown[];
|
|
50
|
+
}
|
|
7
51
|
|
|
8
52
|
/**
|
|
9
53
|
* Reactive inline component that renders atom values directly in JSX.
|
|
10
54
|
*
|
|
11
|
-
* `rx` is a convenience wrapper around `
|
|
55
|
+
* `rx` is a convenience wrapper around `useSelector` that returns a memoized
|
|
12
56
|
* React component instead of a value. This enables fine-grained reactivity
|
|
13
57
|
* without creating separate components for each reactive value.
|
|
14
58
|
*
|
|
@@ -18,25 +62,54 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
18
62
|
*
|
|
19
63
|
* ```tsx
|
|
20
64
|
* // ❌ WRONG - Don't use async function
|
|
21
|
-
* rx(async ({
|
|
65
|
+
* rx(async ({ read }) => {
|
|
22
66
|
* const data = await fetch('/api');
|
|
23
67
|
* return data.name;
|
|
24
68
|
* });
|
|
25
69
|
*
|
|
26
70
|
* // ❌ WRONG - Don't return a Promise
|
|
27
|
-
* rx(({
|
|
71
|
+
* rx(({ read }) => fetch('/api').then(r => r.json()));
|
|
28
72
|
*
|
|
29
|
-
* // ✅ CORRECT - Create async atom and read with
|
|
73
|
+
* // ✅ CORRECT - Create async atom and read with read()
|
|
30
74
|
* const data$ = atom(fetch('/api').then(r => r.json()));
|
|
31
|
-
* rx(({
|
|
75
|
+
* rx(({ read }) => read(data$).name); // Suspends until resolved
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* ## IMPORTANT: Do NOT Use try/catch - Use safe() Instead
|
|
79
|
+
*
|
|
80
|
+
* **Never wrap `read()` calls in try/catch blocks.** The `read()` function throws
|
|
81
|
+
* Promises when atoms are loading (Suspense pattern). A try/catch will catch
|
|
82
|
+
* these Promises and break the Suspense mechanism.
|
|
83
|
+
*
|
|
84
|
+
* ```tsx
|
|
85
|
+
* // ❌ WRONG - Catches Suspense Promise, breaks loading state
|
|
86
|
+
* rx(({ read }) => {
|
|
87
|
+
* try {
|
|
88
|
+
* return <span>{read(user$).name}</span>;
|
|
89
|
+
* } catch (e) {
|
|
90
|
+
* return <span>Error</span>; // Catches BOTH errors AND loading promises!
|
|
91
|
+
* }
|
|
92
|
+
* });
|
|
93
|
+
*
|
|
94
|
+
* // ✅ CORRECT - Use safe() to catch errors but preserve Suspense
|
|
95
|
+
* rx(({ read, safe }) => {
|
|
96
|
+
* const [err, user] = safe(() => read(user$));
|
|
97
|
+
* if (err) return <span>Error: {err.message}</span>;
|
|
98
|
+
* return <span>{user.name}</span>;
|
|
99
|
+
* });
|
|
32
100
|
* ```
|
|
33
101
|
*
|
|
102
|
+
* The `safe()` utility:
|
|
103
|
+
* - **Catches errors** and returns `[error, undefined]`
|
|
104
|
+
* - **Re-throws Promises** to preserve Suspense behavior
|
|
105
|
+
* - Returns `[undefined, result]` on success
|
|
106
|
+
*
|
|
34
107
|
* ## Why Use `rx`?
|
|
35
108
|
*
|
|
36
109
|
* Without `rx`, you need a separate component to subscribe to an atom:
|
|
37
110
|
* ```tsx
|
|
38
111
|
* function PostsList() {
|
|
39
|
-
* const posts =
|
|
112
|
+
* const posts = useSelector(postsAtom);
|
|
40
113
|
* return posts.map((post) => <Post post={post} />);
|
|
41
114
|
* }
|
|
42
115
|
*
|
|
@@ -54,8 +127,8 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
54
127
|
* function Page() {
|
|
55
128
|
* return (
|
|
56
129
|
* <Suspense fallback={<Loading />}>
|
|
57
|
-
* {rx(({
|
|
58
|
-
*
|
|
130
|
+
* {rx(({ read }) =>
|
|
131
|
+
* read(postsAtom).map((post) => <Post post={post} />)
|
|
59
132
|
* )}
|
|
60
133
|
* </Suspense>
|
|
61
134
|
* );
|
|
@@ -73,7 +146,7 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
73
146
|
*
|
|
74
147
|
* ## Async Atoms (Suspense-Style API)
|
|
75
148
|
*
|
|
76
|
-
* `rx` inherits the Suspense-style API from `
|
|
149
|
+
* `rx` inherits the Suspense-style API from `useSelector`:
|
|
77
150
|
* - **Loading state**: The getter throws a Promise (triggers Suspense)
|
|
78
151
|
* - **Error state**: The getter throws the error (triggers ErrorBoundary)
|
|
79
152
|
* - **Resolved state**: The getter returns the value
|
|
@@ -84,7 +157,7 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
84
157
|
* return (
|
|
85
158
|
* <ErrorBoundary fallback={<div>Error!</div>}>
|
|
86
159
|
* <Suspense fallback={<div>Loading...</div>}>
|
|
87
|
-
* {rx(({
|
|
160
|
+
* {rx(({ read }) => read(userAtom).name)}
|
|
88
161
|
* </Suspense>
|
|
89
162
|
* </ErrorBoundary>
|
|
90
163
|
* );
|
|
@@ -93,9 +166,9 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
93
166
|
*
|
|
94
167
|
* Or catch errors in the selector to handle loading/error inline:
|
|
95
168
|
* ```tsx
|
|
96
|
-
* {rx(({
|
|
169
|
+
* {rx(({ read }) => {
|
|
97
170
|
* try {
|
|
98
|
-
* return
|
|
171
|
+
* return read(userAtom).name;
|
|
99
172
|
* } catch {
|
|
100
173
|
* return "Loading...";
|
|
101
174
|
* }
|
|
@@ -103,13 +176,37 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
103
176
|
* ```
|
|
104
177
|
*
|
|
105
178
|
* @template T - The type of the selected/derived value
|
|
106
|
-
* @param selector - Context-based selector function with `{
|
|
179
|
+
* @param selector - Context-based selector function with `{ read, all, any, race, settled }`.
|
|
107
180
|
* Must return sync value, not a Promise.
|
|
108
181
|
* @param equals - Equality function or shorthand ("strict", "shallow", "deep").
|
|
109
182
|
* Defaults to "shallow".
|
|
110
183
|
* @returns A React element that renders the selected value
|
|
111
184
|
* @throws Error if selector returns a Promise or PromiseLike
|
|
112
185
|
*
|
|
186
|
+
* ## IMPORTANT: Atom Value Must Be ReactNode
|
|
187
|
+
*
|
|
188
|
+
* When using the shorthand `rx(atom)`, the atom's value must be a valid `ReactNode`
|
|
189
|
+
* (string, number, boolean, null, undefined, or React element). Objects and arrays
|
|
190
|
+
* are NOT valid ReactNode values and will cause React to throw an error.
|
|
191
|
+
*
|
|
192
|
+
* ```tsx
|
|
193
|
+
* // ✅ CORRECT - Atom contains ReactNode (number)
|
|
194
|
+
* const count$ = atom(5);
|
|
195
|
+
* rx(count$);
|
|
196
|
+
*
|
|
197
|
+
* // ✅ CORRECT - Atom contains ReactNode (string)
|
|
198
|
+
* const name$ = atom("John");
|
|
199
|
+
* rx(name$);
|
|
200
|
+
*
|
|
201
|
+
* // ❌ WRONG - Atom contains object (not ReactNode)
|
|
202
|
+
* const user$ = atom({ name: "John", age: 30 });
|
|
203
|
+
* rx(user$); // React error: "Objects are not valid as a React child"
|
|
204
|
+
*
|
|
205
|
+
* // ✅ CORRECT - Use selector to extract ReactNode from object
|
|
206
|
+
* rx(({ read }) => read(user$).name);
|
|
207
|
+
* rx(({ read }) => <UserCard user={read(user$)} />);
|
|
208
|
+
* ```
|
|
209
|
+
*
|
|
113
210
|
* @example Shorthand - render atom value directly
|
|
114
211
|
* ```tsx
|
|
115
212
|
* const count = atom(5);
|
|
@@ -124,7 +221,7 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
124
221
|
* const count = atom(5);
|
|
125
222
|
*
|
|
126
223
|
* function DoubledCounter() {
|
|
127
|
-
* return <div>Doubled: {rx(({
|
|
224
|
+
* return <div>Doubled: {rx(({ read }) => read(count) * 2)}</div>;
|
|
128
225
|
* }
|
|
129
226
|
* ```
|
|
130
227
|
*
|
|
@@ -136,7 +233,7 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
136
233
|
* function FullName() {
|
|
137
234
|
* return (
|
|
138
235
|
* <div>
|
|
139
|
-
* {rx(({
|
|
236
|
+
* {rx(({ read }) => `${read(firstName)} ${read(lastName)}`)}
|
|
140
237
|
* </div>
|
|
141
238
|
* );
|
|
142
239
|
* }
|
|
@@ -163,14 +260,14 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
163
260
|
* return (
|
|
164
261
|
* <div>
|
|
165
262
|
* <header>
|
|
166
|
-
* <Suspense fallback="...">{rx(({
|
|
263
|
+
* <Suspense fallback="...">{rx(({ read }) => read(userAtom).name)}</Suspense>
|
|
167
264
|
* </header>
|
|
168
265
|
* <main>
|
|
169
266
|
* <Suspense fallback="...">
|
|
170
|
-
* {rx(({
|
|
267
|
+
* {rx(({ read }) => read(postsAtom).length)} posts
|
|
171
268
|
* </Suspense>
|
|
172
269
|
* <Suspense fallback="...">
|
|
173
|
-
* {rx(({
|
|
270
|
+
* {rx(({ read }) => read(notificationsAtom).length)} notifications
|
|
174
271
|
* </Suspense>
|
|
175
272
|
* </main>
|
|
176
273
|
* </div>
|
|
@@ -187,8 +284,8 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
187
284
|
* function Info() {
|
|
188
285
|
* return (
|
|
189
286
|
* <div>
|
|
190
|
-
* {rx(({
|
|
191
|
-
*
|
|
287
|
+
* {rx(({ read }) =>
|
|
288
|
+
* read(showDetails) ? read(details) : read(summary)
|
|
192
289
|
* )}
|
|
193
290
|
* </div>
|
|
194
291
|
* );
|
|
@@ -203,7 +300,7 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
203
300
|
* return (
|
|
204
301
|
* <div>
|
|
205
302
|
* {rx(
|
|
206
|
-
* ({
|
|
303
|
+
* ({ read }) => read(user).name,
|
|
207
304
|
* (a, b) => a === b // Only re-render if name string changes
|
|
208
305
|
* )}
|
|
209
306
|
* </div>
|
|
@@ -221,7 +318,7 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
221
318
|
* <Suspense fallback={<Loading />}>
|
|
222
319
|
* {rx(({ all }) => {
|
|
223
320
|
* // Use all() to wait for multiple atoms
|
|
224
|
-
* const [user, posts] = all([
|
|
321
|
+
* const [user, posts] = all([user$, posts$]);
|
|
225
322
|
* return <DashboardContent user={user} posts={posts} />;
|
|
226
323
|
* })}
|
|
227
324
|
* </Suspense>
|
|
@@ -252,23 +349,133 @@ import { ContextSelectorFn } from "../core/select";
|
|
|
252
349
|
* ```
|
|
253
350
|
*/
|
|
254
351
|
// Overload: Pass atom directly to get its value (shorthand)
|
|
255
|
-
export function rx<T
|
|
352
|
+
export function rx<T extends ReactNode | PromiseLike<ReactNode>>(
|
|
353
|
+
atom: Atom<T>,
|
|
354
|
+
options?: Equality<T> | RxOptions<T>
|
|
355
|
+
): ReactElement;
|
|
256
356
|
|
|
257
357
|
// Overload: Context-based selector function
|
|
258
|
-
export function rx<T
|
|
358
|
+
export function rx<T extends ReactNode | PromiseLike<ReactNode>>(
|
|
359
|
+
selector: ReactiveSelector<T>,
|
|
360
|
+
options?: Equality<T> | RxOptions<T>
|
|
361
|
+
): ReactElement;
|
|
259
362
|
|
|
260
363
|
export function rx<T>(
|
|
261
|
-
selectorOrAtom:
|
|
262
|
-
|
|
263
|
-
):
|
|
364
|
+
selectorOrAtom: ReactiveSelector<T> | Atom<T>,
|
|
365
|
+
options?: Equality<unknown> | RxOptions<unknown>
|
|
366
|
+
): ReactElement {
|
|
367
|
+
// Normalize options
|
|
368
|
+
const normalizedOptions: RxOptions<unknown> | undefined =
|
|
369
|
+
options === undefined
|
|
370
|
+
? undefined
|
|
371
|
+
: typeof options === "object" &&
|
|
372
|
+
options !== null &&
|
|
373
|
+
!Array.isArray(options) &&
|
|
374
|
+
("equals" in options || "loading" in options || "error" in options)
|
|
375
|
+
? (options as RxOptions<unknown>)
|
|
376
|
+
: { equals: options as Equality<unknown> };
|
|
377
|
+
|
|
264
378
|
return (
|
|
265
379
|
<Rx
|
|
266
380
|
selectorOrAtom={
|
|
267
|
-
selectorOrAtom as
|
|
381
|
+
selectorOrAtom as ReactiveSelector<unknown> | Atom<unknown>
|
|
268
382
|
}
|
|
269
|
-
|
|
383
|
+
options={normalizedOptions}
|
|
270
384
|
/>
|
|
271
|
-
)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Internal ErrorBoundary for rx with error handler.
|
|
390
|
+
*/
|
|
391
|
+
interface RxErrorBoundaryProps {
|
|
392
|
+
children: ReactNode;
|
|
393
|
+
onError?: (props: { error: unknown }) => ReactNode;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
interface RxErrorBoundaryState {
|
|
397
|
+
error: unknown | null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
class RxErrorBoundary extends Component<
|
|
401
|
+
RxErrorBoundaryProps,
|
|
402
|
+
RxErrorBoundaryState
|
|
403
|
+
> {
|
|
404
|
+
state: RxErrorBoundaryState = { error: null };
|
|
405
|
+
|
|
406
|
+
static getDerivedStateFromError(error: unknown): RxErrorBoundaryState {
|
|
407
|
+
return { error };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
411
|
+
componentDidCatch(_error: Error, _errorInfo: ErrorInfo) {
|
|
412
|
+
// Error already captured in state
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
render() {
|
|
416
|
+
if (this.state.error !== null && this.props.onError) {
|
|
417
|
+
return <>{this.props.onError({ error: this.state.error })}</>;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (this.state.error !== null) {
|
|
421
|
+
// No handler - re-throw to parent ErrorBoundary
|
|
422
|
+
throw this.state.error;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return this.props.children;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Internal component that renders the selector value.
|
|
431
|
+
*/
|
|
432
|
+
function RxInner(props: {
|
|
433
|
+
selector: ReactiveSelector<unknown>;
|
|
434
|
+
equals?: Equality<unknown>;
|
|
435
|
+
}) {
|
|
436
|
+
const selected = useSelector(props.selector, props.equals);
|
|
437
|
+
return <>{selected ?? null}</>;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Wrapper component to defer loading() call until actually needed.
|
|
442
|
+
*/
|
|
443
|
+
function RxLoadingFallback(props: { render: () => ReactNode }) {
|
|
444
|
+
return <>{props.render()}</>;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Optional Suspense wrapper - only wraps if fallback is provided.
|
|
449
|
+
*/
|
|
450
|
+
function RxSuspenseWrapper(props: {
|
|
451
|
+
fallback?: () => ReactNode;
|
|
452
|
+
children: ReactNode;
|
|
453
|
+
}) {
|
|
454
|
+
if (props.fallback) {
|
|
455
|
+
return (
|
|
456
|
+
<Suspense fallback={<RxLoadingFallback render={props.fallback} />}>
|
|
457
|
+
{props.children}
|
|
458
|
+
</Suspense>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
return <>{props.children}</>;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Optional ErrorBoundary wrapper - only wraps if onError is provided.
|
|
466
|
+
*/
|
|
467
|
+
function RxErrorWrapper(props: {
|
|
468
|
+
onError?: (props: { error: unknown }) => ReactNode;
|
|
469
|
+
children: ReactNode;
|
|
470
|
+
}) {
|
|
471
|
+
if (props.onError) {
|
|
472
|
+
return (
|
|
473
|
+
<RxErrorBoundary onError={props.onError}>
|
|
474
|
+
{props.children}
|
|
475
|
+
</RxErrorBoundary>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return <>{props.children}</>;
|
|
272
479
|
}
|
|
273
480
|
|
|
274
481
|
/**
|
|
@@ -277,24 +484,48 @@ export function rx<T>(
|
|
|
277
484
|
* Memoized with React.memo to ensure:
|
|
278
485
|
* 1. Parent components don't cause unnecessary re-renders
|
|
279
486
|
* 2. Only atom changes trigger re-renders
|
|
280
|
-
* 3. Props comparison is shallow (selectorOrAtom,
|
|
487
|
+
* 3. Props comparison is shallow (selectorOrAtom, options references)
|
|
281
488
|
*
|
|
282
489
|
* Renders `selected ?? null` to handle null/undefined values gracefully in JSX.
|
|
283
490
|
*/
|
|
284
491
|
const Rx = memo(
|
|
285
492
|
function Rx(props: {
|
|
286
|
-
selectorOrAtom:
|
|
287
|
-
|
|
493
|
+
selectorOrAtom: ReactiveSelector<unknown> | Atom<unknown>;
|
|
494
|
+
options?: RxOptions<unknown>;
|
|
288
495
|
}) {
|
|
289
|
-
//
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
496
|
+
// Store latest selector/atom in ref to avoid stale closures
|
|
497
|
+
const selectorRef = useRef(props.selectorOrAtom);
|
|
498
|
+
selectorRef.current = props.selectorOrAtom;
|
|
499
|
+
|
|
500
|
+
// Compute memoization dependencies:
|
|
501
|
+
// - Atom: always include atom reference for stability
|
|
502
|
+
// - Function + no deps: new object each render (no memoization)
|
|
503
|
+
// - Function + deps: use provided deps for controlled memoization
|
|
504
|
+
const isAtomInput = isAtom(props.selectorOrAtom);
|
|
505
|
+
const userDeps = props.options?.deps;
|
|
506
|
+
const deps = isAtomInput
|
|
507
|
+
? [props.selectorOrAtom, ...(userDeps ?? [])] // Atom: stable + optional user deps
|
|
508
|
+
: (userDeps ?? [{}]); // Function: user deps or no memoization
|
|
509
|
+
|
|
510
|
+
// Memoized selector that reads from ref to always get latest value
|
|
511
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
512
|
+
const selector = useCallback(
|
|
513
|
+
(context: SelectContext) =>
|
|
514
|
+
isAtom(selectorRef.current)
|
|
515
|
+
? context.read(selectorRef.current as Atom<unknown>)
|
|
516
|
+
: (selectorRef.current as ReactiveSelector<unknown>)(context),
|
|
517
|
+
deps
|
|
518
|
+
);
|
|
293
519
|
|
|
294
|
-
|
|
295
|
-
|
|
520
|
+
return (
|
|
521
|
+
<RxErrorWrapper onError={props.options?.error}>
|
|
522
|
+
<RxSuspenseWrapper fallback={props.options?.loading}>
|
|
523
|
+
<RxInner selector={selector} equals={props.options?.equals} />
|
|
524
|
+
</RxSuspenseWrapper>
|
|
525
|
+
</RxErrorWrapper>
|
|
526
|
+
);
|
|
296
527
|
},
|
|
297
528
|
(prev, next) =>
|
|
298
529
|
shallowEqual(prev.selectorOrAtom, next.selectorOrAtom) &&
|
|
299
|
-
prev.
|
|
530
|
+
shallowEqual(prev.options, next.options)
|
|
300
531
|
);
|
|
@@ -28,9 +28,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
28
28
|
it("should execute on mount when lazy is false", () => {
|
|
29
29
|
const fn = vi.fn(() => "result");
|
|
30
30
|
|
|
31
|
-
const { result } = renderHook(() =>
|
|
32
|
-
useAction(fn, { lazy: false })
|
|
33
|
-
);
|
|
31
|
+
const { result } = renderHook(() => useAction(fn, { lazy: false }));
|
|
34
32
|
|
|
35
33
|
// Sync function completes immediately, so status is success
|
|
36
34
|
expect(result.current.status).toBe("success");
|
|
@@ -47,9 +45,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
47
45
|
})
|
|
48
46
|
);
|
|
49
47
|
|
|
50
|
-
const { result } = renderHook(() =>
|
|
51
|
-
useAction(fn, { lazy: false })
|
|
52
|
-
);
|
|
48
|
+
const { result } = renderHook(() => useAction(fn, { lazy: false }));
|
|
53
49
|
|
|
54
50
|
expect(result.current.status).toBe("loading");
|
|
55
51
|
// In strict mode, effects run twice
|
|
@@ -248,7 +244,9 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
248
244
|
result.current();
|
|
249
245
|
});
|
|
250
246
|
|
|
251
|
-
expect(fn).toHaveBeenCalledWith(
|
|
247
|
+
expect(fn).toHaveBeenCalledWith(
|
|
248
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
|
249
|
+
);
|
|
252
250
|
});
|
|
253
251
|
|
|
254
252
|
it("should create new AbortSignal per call", () => {
|
|
@@ -802,7 +800,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
802
800
|
describe("atom deps", () => {
|
|
803
801
|
it("should execute when atom in deps changes", () => {
|
|
804
802
|
const userId = atom(1);
|
|
805
|
-
const fn = vi.fn(() => `user-${userId.
|
|
803
|
+
const fn = vi.fn(() => `user-${userId.get()}`);
|
|
806
804
|
|
|
807
805
|
const { result } = renderHook(() =>
|
|
808
806
|
useAction(fn, { lazy: false, deps: [userId] })
|
|
@@ -825,7 +823,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
825
823
|
it("should NOT re-execute when atom value is shallowly equal", () => {
|
|
826
824
|
// Use atom with shallow equals so it doesn't notify on shallow equal values
|
|
827
825
|
const config = atom({ page: 1 }, { equals: "shallow" });
|
|
828
|
-
const fn = vi.fn(() => `page-${config.
|
|
826
|
+
const fn = vi.fn(() => `page-${config.get()?.page}`);
|
|
829
827
|
|
|
830
828
|
renderHook(() => useAction(fn, { lazy: false, deps: [config] }));
|
|
831
829
|
|
|
@@ -847,7 +845,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
847
845
|
// Even if atom notifies, if the selected values are shallow equal,
|
|
848
846
|
// the effect should not re-run
|
|
849
847
|
const userId = atom(1);
|
|
850
|
-
const fn = vi.fn(() => `user-${userId.
|
|
848
|
+
const fn = vi.fn(() => `user-${userId.get()}`);
|
|
851
849
|
|
|
852
850
|
renderHook(() => useAction(fn, { lazy: false, deps: [userId] }));
|
|
853
851
|
|
|
@@ -866,7 +864,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
866
864
|
|
|
867
865
|
it("should re-execute when atom value changes (not shallow equal)", () => {
|
|
868
866
|
const config = atom({ page: 1 });
|
|
869
|
-
const fn = vi.fn(() => `page-${config.
|
|
867
|
+
const fn = vi.fn(() => `page-${config.get()?.page}`);
|
|
870
868
|
|
|
871
869
|
const { result } = renderHook(() =>
|
|
872
870
|
useAction(fn, { lazy: false, deps: [config] })
|
|
@@ -915,7 +913,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
915
913
|
|
|
916
914
|
it("should NOT track atoms when lazy is true (default)", () => {
|
|
917
915
|
const userId = atom(1);
|
|
918
|
-
const fn = vi.fn(() => `user-${userId.
|
|
916
|
+
const fn = vi.fn(() => `user-${userId.get()}`);
|
|
919
917
|
|
|
920
918
|
renderHook(() => useAction(fn, { lazy: true, deps: [userId] }));
|
|
921
919
|
|
|
@@ -936,7 +934,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
936
934
|
const fn = vi.fn(({ signal }: { signal: AbortSignal }) => {
|
|
937
935
|
signals.push(signal);
|
|
938
936
|
return new Promise<string>((resolve) => {
|
|
939
|
-
setTimeout(() => resolve(`user-${userId.
|
|
937
|
+
setTimeout(() => resolve(`user-${userId.get()}`), 1000);
|
|
940
938
|
});
|
|
941
939
|
});
|
|
942
940
|
|
|
@@ -958,7 +956,7 @@ describe.each(wrappers)("useAction - $mode", ({ mode, renderHook }) => {
|
|
|
958
956
|
it("should work with multiple atoms in deps", () => {
|
|
959
957
|
const userId = atom(1);
|
|
960
958
|
const orgId = atom(100);
|
|
961
|
-
const fn = vi.fn(() => `user-${userId.
|
|
959
|
+
const fn = vi.fn(() => `user-${userId.get()}-org-${orgId.get()}`);
|
|
962
960
|
|
|
963
961
|
const { result } = renderHook(() =>
|
|
964
962
|
useAction(fn, { lazy: false, deps: [userId, orgId] })
|
package/src/react/useAction.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useReducer, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import { isPromiseLike } from "../core/isPromiseLike";
|
|
3
|
-
import {
|
|
3
|
+
import { useSelector } from "./useSelector";
|
|
4
4
|
import { isAtom } from "../core/isAtom";
|
|
5
|
+
import { Pipeable } from "../core/types";
|
|
6
|
+
import { withUse } from "../core/withUse";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* State for an action that hasn't been dispatched yet.
|
|
@@ -76,7 +78,7 @@ export interface UseActionOptions {
|
|
|
76
78
|
/**
|
|
77
79
|
* Dependencies array. When lazy is false, re-executes when deps change.
|
|
78
80
|
* - Regular values: compared by reference (like useEffect deps)
|
|
79
|
-
* - Atoms: automatically tracked via
|
|
81
|
+
* - Atoms: automatically tracked via useSelector, re-executes when atom values change
|
|
80
82
|
* @default []
|
|
81
83
|
*/
|
|
82
84
|
deps?: unknown[];
|
|
@@ -85,7 +87,7 @@ export interface UseActionOptions {
|
|
|
85
87
|
/**
|
|
86
88
|
* Context passed to the action function.
|
|
87
89
|
*/
|
|
88
|
-
export interface ActionContext {
|
|
90
|
+
export interface ActionContext extends Pipeable {
|
|
89
91
|
/** AbortSignal for cancellation. New signal per dispatch. */
|
|
90
92
|
signal: AbortSignal;
|
|
91
93
|
}
|
|
@@ -305,11 +307,11 @@ function reducer<T>(
|
|
|
305
307
|
*
|
|
306
308
|
* function UserProfile() {
|
|
307
309
|
* const fetchUser = useAction(
|
|
308
|
-
* async ({ signal }) => fetchUserApi(userIdAtom.
|
|
310
|
+
* async ({ signal }) => fetchUserApi(userIdAtom.get(), { signal }),
|
|
309
311
|
* { lazy: false, deps: [userIdAtom] }
|
|
310
312
|
* );
|
|
311
313
|
* // Automatically re-fetches when userIdAtom changes
|
|
312
|
-
* // Atoms in deps are tracked reactively via
|
|
314
|
+
* // Atoms in deps are tracked reactively via useSelector
|
|
313
315
|
* }
|
|
314
316
|
* ```
|
|
315
317
|
*
|
|
@@ -487,9 +489,9 @@ export function useAction<TResult, TLazy extends boolean = true>(
|
|
|
487
489
|
// Get atoms from deps for reactive tracking
|
|
488
490
|
const atomDeps = (lazy ? [] : (deps ?? [])).filter(isAtom);
|
|
489
491
|
|
|
490
|
-
// Use
|
|
491
|
-
const atomValues =
|
|
492
|
-
return atomDeps.map((atom) =>
|
|
492
|
+
// Use useSelector to track atom deps and get their values for effect deps comparison
|
|
493
|
+
const atomValues = useSelector(({ read }) => {
|
|
494
|
+
return atomDeps.map((atom) => read(atom));
|
|
493
495
|
});
|
|
494
496
|
|
|
495
497
|
const dispatch = useCallback((): AbortablePromise<Awaited<TResult>> => {
|
|
@@ -506,7 +508,7 @@ export function useAction<TResult, TLazy extends boolean = true>(
|
|
|
506
508
|
|
|
507
509
|
let result: TResult;
|
|
508
510
|
try {
|
|
509
|
-
result = fnRef.current({ signal: abortController.signal });
|
|
511
|
+
result = fnRef.current(withUse({ signal: abortController.signal }));
|
|
510
512
|
} catch (error) {
|
|
511
513
|
// Sync error - update state and return rejected promise
|
|
512
514
|
dispatchAction({ type: "ERROR", error });
|