@montra-interactive/deepstate-react 0.3.5 → 0.3.6
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 +19 -2
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.js +49 -18
- package/package.json +1 -1
- package/src/hooks.ts +130 -20
package/README.md
CHANGED
|
@@ -103,15 +103,32 @@ const summary = useSelect(
|
|
|
103
103
|
);
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
#### Selector Memoization
|
|
107
|
+
|
|
108
|
+
`useSelect` automatically memoizes selectors: the selector function only re-runs when its input values actually change (compared by reference). This means you can safely return new arrays or objects from selectors without causing infinite re-render loops:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
// Safe! The selector only re-runs when items or sortBy actually change.
|
|
112
|
+
// Even though .sort() returns a new array, it won't cause infinite emissions.
|
|
113
|
+
const sorted = useSelect(
|
|
114
|
+
[store.items, store.sortBy],
|
|
115
|
+
([items, sortBy]) => Array.from(items).sort((a, b) =>
|
|
116
|
+
sortBy === 'name' ? a.name.localeCompare(b.name) : b.updatedAt - a.updatedAt
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
This works like Redux's `createSelector` / Reselect — if the inputs haven't changed, the selector doesn't re-run, preserving the previous output reference.
|
|
122
|
+
|
|
106
123
|
#### Custom Equality Function
|
|
107
124
|
|
|
108
|
-
|
|
125
|
+
For additional control, provide a custom equality check as the third argument. This is evaluated on the selector's **output** and acts as a safety net when different inputs might produce equivalent results:
|
|
109
126
|
|
|
110
127
|
```tsx
|
|
111
128
|
const ids = useSelect(
|
|
112
129
|
store.items,
|
|
113
130
|
items => items.map(i => i.id),
|
|
114
|
-
// Custom array equality
|
|
131
|
+
// Custom array equality on the output
|
|
115
132
|
(a, b) => a.length === b.length && a.every((v, i) => v === b[i])
|
|
116
133
|
);
|
|
117
134
|
```
|
package/dist/hooks.d.ts
CHANGED
|
@@ -42,6 +42,20 @@ export declare function useObservable<T>(observable$: Observable<T>, getSnapshot
|
|
|
42
42
|
* This is the primary hook for using deepstate in React.
|
|
43
43
|
* Uses React 18's useSyncExternalStore for concurrent-mode safety.
|
|
44
44
|
*
|
|
45
|
+
* ## Selector Memoization
|
|
46
|
+
*
|
|
47
|
+
* Selectors are automatically memoized on their inputs, similar to Redux's
|
|
48
|
+
* `createSelector` / Reselect. The selector function only re-executes when
|
|
49
|
+
* input values change by reference. This means selectors that return new
|
|
50
|
+
* arrays or objects (e.g. via `.sort()`, `.filter()`, `.map()`) are safe
|
|
51
|
+
* without needing custom equality functions.
|
|
52
|
+
*
|
|
53
|
+
* Memoization works in two layers:
|
|
54
|
+
* 1. **Input dedup** — `distinctUntilChanged` before the selector prevents
|
|
55
|
+
* re-execution when inputs are referentially identical.
|
|
56
|
+
* 2. **Output dedup** — `distinctUntilChanged(equalityFn)` after the selector
|
|
57
|
+
* catches cases where different inputs produce equivalent outputs.
|
|
58
|
+
*
|
|
45
59
|
* @example Single node (get raw value)
|
|
46
60
|
* ```tsx
|
|
47
61
|
* import { state } from 'deepstate';
|
|
@@ -83,6 +97,22 @@ export declare function useObservable<T>(observable$: Observable<T>, getSnapshot
|
|
|
83
97
|
* }
|
|
84
98
|
* ```
|
|
85
99
|
*
|
|
100
|
+
* @example Selector returning new array (safe - auto-memoized)
|
|
101
|
+
* ```tsx
|
|
102
|
+
* // .sort() returns a new array each time, but the selector only
|
|
103
|
+
* // re-runs when items or sortBy actually change.
|
|
104
|
+
* function SortedItems() {
|
|
105
|
+
* const sorted = useSelect(
|
|
106
|
+
* [store.items, store.sortBy],
|
|
107
|
+
* ([items, sortBy]) =>
|
|
108
|
+
* Array.from(items).sort((a, b) =>
|
|
109
|
+
* sortBy === 'name' ? a.name.localeCompare(b.name) : b.date - a.date
|
|
110
|
+
* ),
|
|
111
|
+
* );
|
|
112
|
+
* return <ItemList items={sorted} />;
|
|
113
|
+
* }
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
86
116
|
* @example Multiple nodes (array form)
|
|
87
117
|
* ```tsx
|
|
88
118
|
* // Combine multiple nodes - receives values as tuple
|
package/dist/hooks.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAwBvC;;;GAGG;AACH,UAAU,WAAW,CAAC,CAAC;IACrB,GAAG,IAAI,CAAC,CAAC;CACV;AAED;;;GAGG;AACH,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,34 @@ function isObservable(obj) {
|
|
|
20
20
|
return false;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
function useStableNodes(nodeOrNodes) {
|
|
24
|
+
const ref = useRef(nodeOrNodes);
|
|
25
|
+
if (Array.isArray(nodeOrNodes)) {
|
|
26
|
+
const prev = ref.current;
|
|
27
|
+
if (!Array.isArray(prev) || prev.length !== nodeOrNodes.length || nodeOrNodes.some((n, i) => n !== prev[i])) {
|
|
28
|
+
ref.current = nodeOrNodes;
|
|
29
|
+
}
|
|
30
|
+
return ref.current;
|
|
31
|
+
}
|
|
32
|
+
if (!isObservable(nodeOrNodes)) {
|
|
33
|
+
const prev = ref.current;
|
|
34
|
+
if (isObservable(prev) || Array.isArray(prev)) {
|
|
35
|
+
ref.current = nodeOrNodes;
|
|
36
|
+
return ref.current;
|
|
37
|
+
}
|
|
38
|
+
const prevObj = prev;
|
|
39
|
+
const currKeys = Object.keys(nodeOrNodes);
|
|
40
|
+
const prevKeys = Object.keys(prevObj);
|
|
41
|
+
if (currKeys.length !== prevKeys.length || currKeys.some((k) => nodeOrNodes[k] !== prevObj[k])) {
|
|
42
|
+
ref.current = nodeOrNodes;
|
|
43
|
+
}
|
|
44
|
+
return ref.current;
|
|
45
|
+
}
|
|
46
|
+
if (nodeOrNodes !== ref.current) {
|
|
47
|
+
ref.current = nodeOrNodes;
|
|
48
|
+
}
|
|
49
|
+
return ref.current;
|
|
50
|
+
}
|
|
23
51
|
function useObservable(observable$, getSnapshot) {
|
|
24
52
|
const valueRef = useRef(getSnapshot());
|
|
25
53
|
const subscribe = useCallback((onStoreChange) => {
|
|
@@ -33,55 +61,58 @@ function useObservable(observable$, getSnapshot) {
|
|
|
33
61
|
return useSyncExternalStore(subscribe, getSnapshotMemo, getSnapshotMemo);
|
|
34
62
|
}
|
|
35
63
|
function useSelect(nodeOrNodes, selector, equalityFn = Object.is) {
|
|
64
|
+
const selectorRef = useRef(selector);
|
|
65
|
+
selectorRef.current = selector;
|
|
66
|
+
const equalityFnRef = useRef(equalityFn);
|
|
67
|
+
equalityFnRef.current = equalityFn;
|
|
68
|
+
const stableNodes = useStableNodes(nodeOrNodes);
|
|
36
69
|
const { combined$, getInitialValue } = useMemo(() => {
|
|
37
|
-
if (Array.isArray(
|
|
38
|
-
const nodes =
|
|
39
|
-
const sel = selector;
|
|
70
|
+
if (Array.isArray(stableNodes)) {
|
|
71
|
+
const nodes = stableNodes;
|
|
40
72
|
return {
|
|
41
|
-
combined$: combineLatest(nodes).pipe(map((values) =>
|
|
73
|
+
combined$: combineLatest(nodes).pipe(distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => Object.is(v, b[i]))), map((values) => selectorRef.current(values)), distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
|
|
42
74
|
getInitialValue: () => {
|
|
43
75
|
const values = nodes.map((n) => hasGet(n) ? n.get() : undefined);
|
|
44
|
-
return
|
|
76
|
+
return selectorRef.current(values);
|
|
45
77
|
}
|
|
46
78
|
};
|
|
47
79
|
}
|
|
48
|
-
if (!isObservable(
|
|
49
|
-
const obj =
|
|
80
|
+
if (!isObservable(stableNodes)) {
|
|
81
|
+
const obj = stableNodes;
|
|
50
82
|
const keys = Object.keys(obj);
|
|
51
83
|
const observables = keys.map((k) => obj[k]);
|
|
52
|
-
const sel = selector;
|
|
53
84
|
return {
|
|
54
|
-
combined$: combineLatest(observables).pipe(map((values) => {
|
|
85
|
+
combined$: combineLatest(observables).pipe(distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => Object.is(v, b[i]))), map((values) => {
|
|
55
86
|
const result = {};
|
|
56
87
|
keys.forEach((key, i) => {
|
|
57
88
|
result[key] = values[i];
|
|
58
89
|
});
|
|
59
|
-
return
|
|
60
|
-
}), distinctUntilChanged(
|
|
90
|
+
return selectorRef.current(result);
|
|
91
|
+
}), distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
|
|
61
92
|
getInitialValue: () => {
|
|
62
93
|
const result = {};
|
|
63
94
|
keys.forEach((key) => {
|
|
64
95
|
const node2 = obj[key];
|
|
65
96
|
result[key] = hasGet(node2) ? node2.get() : undefined;
|
|
66
97
|
});
|
|
67
|
-
return
|
|
98
|
+
return selectorRef.current(result);
|
|
68
99
|
}
|
|
69
100
|
};
|
|
70
101
|
}
|
|
71
|
-
const node =
|
|
72
|
-
if (
|
|
102
|
+
const node = stableNodes;
|
|
103
|
+
if (selectorRef.current) {
|
|
73
104
|
return {
|
|
74
|
-
combined$: node.pipe(map((value) =>
|
|
105
|
+
combined$: node.pipe(distinctUntilChanged(), map((value) => selectorRef.current(value)), distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
|
|
75
106
|
getInitialValue: () => {
|
|
76
107
|
if (hasGet(node)) {
|
|
77
|
-
return
|
|
108
|
+
return selectorRef.current(node.get());
|
|
78
109
|
}
|
|
79
110
|
return;
|
|
80
111
|
}
|
|
81
112
|
};
|
|
82
113
|
} else {
|
|
83
114
|
return {
|
|
84
|
-
combined$: node.pipe(distinctUntilChanged(
|
|
115
|
+
combined$: node.pipe(distinctUntilChanged((a, b) => equalityFnRef.current(a, b))),
|
|
85
116
|
getInitialValue: () => {
|
|
86
117
|
if (hasGet(node)) {
|
|
87
118
|
return node.get();
|
|
@@ -90,7 +121,7 @@ function useSelect(nodeOrNodes, selector, equalityFn = Object.is) {
|
|
|
90
121
|
}
|
|
91
122
|
};
|
|
92
123
|
}
|
|
93
|
-
}, [
|
|
124
|
+
}, [stableNodes]);
|
|
94
125
|
const valueRef = useRef(getInitialValue());
|
|
95
126
|
const subscribe = useCallback((onStoreChange) => {
|
|
96
127
|
const subscription = combined$.subscribe((newValue) => {
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -59,6 +59,62 @@ function isObservable(obj: unknown): obj is Observable<unknown> {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Stabilizes the identity of nodeOrNodes across re-renders.
|
|
64
|
+
*
|
|
65
|
+
* Users typically pass inline arrays like `[store.a, store.b]` or inline objects
|
|
66
|
+
* like `{ a: store.a }` to useSelect. These are new references every render,
|
|
67
|
+
* which would cause useMemo to recreate the observable pipeline, leading to
|
|
68
|
+
* resubscription and potential infinite render loops (shareReplay replays the
|
|
69
|
+
* last value → onStoreChange → re-render → new useMemo → resubscribe → replay → …).
|
|
70
|
+
*
|
|
71
|
+
* This hook compares the individual node references inside the container and
|
|
72
|
+
* returns a stable reference as long as the nodes themselves haven't changed.
|
|
73
|
+
*/
|
|
74
|
+
type NodeInput = Observable<unknown> | Observable<unknown>[] | Record<string, Observable<unknown>>;
|
|
75
|
+
|
|
76
|
+
function useStableNodes(nodeOrNodes: NodeInput): NodeInput {
|
|
77
|
+
const ref = useRef(nodeOrNodes);
|
|
78
|
+
|
|
79
|
+
// For arrays: check element-wise identity
|
|
80
|
+
if (Array.isArray(nodeOrNodes)) {
|
|
81
|
+
const prev = ref.current;
|
|
82
|
+
if (
|
|
83
|
+
!Array.isArray(prev) ||
|
|
84
|
+
prev.length !== nodeOrNodes.length ||
|
|
85
|
+
nodeOrNodes.some((n, i) => n !== (prev as Observable<unknown>[])[i])
|
|
86
|
+
) {
|
|
87
|
+
ref.current = nodeOrNodes;
|
|
88
|
+
}
|
|
89
|
+
return ref.current;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// For objects (not observable): check key-wise identity
|
|
93
|
+
if (!isObservable(nodeOrNodes)) {
|
|
94
|
+
const prev = ref.current;
|
|
95
|
+
if (isObservable(prev) || Array.isArray(prev)) {
|
|
96
|
+
ref.current = nodeOrNodes;
|
|
97
|
+
return ref.current;
|
|
98
|
+
}
|
|
99
|
+
const prevObj = prev as Record<string, Observable<unknown>>;
|
|
100
|
+
const currKeys = Object.keys(nodeOrNodes);
|
|
101
|
+
const prevKeys = Object.keys(prevObj);
|
|
102
|
+
if (
|
|
103
|
+
currKeys.length !== prevKeys.length ||
|
|
104
|
+
currKeys.some((k) => nodeOrNodes[k] !== prevObj[k])
|
|
105
|
+
) {
|
|
106
|
+
ref.current = nodeOrNodes;
|
|
107
|
+
}
|
|
108
|
+
return ref.current;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For single observable: direct identity check
|
|
112
|
+
if (nodeOrNodes !== ref.current) {
|
|
113
|
+
ref.current = nodeOrNodes;
|
|
114
|
+
}
|
|
115
|
+
return ref.current;
|
|
116
|
+
}
|
|
117
|
+
|
|
62
118
|
/**
|
|
63
119
|
* Hook to subscribe to any Observable and get its current value.
|
|
64
120
|
* Re-renders the component whenever the observable emits a new value.
|
|
@@ -112,6 +168,20 @@ export function useObservable<T>(
|
|
|
112
168
|
* This is the primary hook for using deepstate in React.
|
|
113
169
|
* Uses React 18's useSyncExternalStore for concurrent-mode safety.
|
|
114
170
|
*
|
|
171
|
+
* ## Selector Memoization
|
|
172
|
+
*
|
|
173
|
+
* Selectors are automatically memoized on their inputs, similar to Redux's
|
|
174
|
+
* `createSelector` / Reselect. The selector function only re-executes when
|
|
175
|
+
* input values change by reference. This means selectors that return new
|
|
176
|
+
* arrays or objects (e.g. via `.sort()`, `.filter()`, `.map()`) are safe
|
|
177
|
+
* without needing custom equality functions.
|
|
178
|
+
*
|
|
179
|
+
* Memoization works in two layers:
|
|
180
|
+
* 1. **Input dedup** — `distinctUntilChanged` before the selector prevents
|
|
181
|
+
* re-execution when inputs are referentially identical.
|
|
182
|
+
* 2. **Output dedup** — `distinctUntilChanged(equalityFn)` after the selector
|
|
183
|
+
* catches cases where different inputs produce equivalent outputs.
|
|
184
|
+
*
|
|
115
185
|
* @example Single node (get raw value)
|
|
116
186
|
* ```tsx
|
|
117
187
|
* import { state } from 'deepstate';
|
|
@@ -153,6 +223,22 @@ export function useObservable<T>(
|
|
|
153
223
|
* }
|
|
154
224
|
* ```
|
|
155
225
|
*
|
|
226
|
+
* @example Selector returning new array (safe - auto-memoized)
|
|
227
|
+
* ```tsx
|
|
228
|
+
* // .sort() returns a new array each time, but the selector only
|
|
229
|
+
* // re-runs when items or sortBy actually change.
|
|
230
|
+
* function SortedItems() {
|
|
231
|
+
* const sorted = useSelect(
|
|
232
|
+
* [store.items, store.sortBy],
|
|
233
|
+
* ([items, sortBy]) =>
|
|
234
|
+
* Array.from(items).sort((a, b) =>
|
|
235
|
+
* sortBy === 'name' ? a.name.localeCompare(b.name) : b.date - a.date
|
|
236
|
+
* ),
|
|
237
|
+
* );
|
|
238
|
+
* return <ItemList items={sorted} />;
|
|
239
|
+
* }
|
|
240
|
+
* ```
|
|
241
|
+
*
|
|
156
242
|
* @example Multiple nodes (array form)
|
|
157
243
|
* ```tsx
|
|
158
244
|
* // Combine multiple nodes - receives values as tuple
|
|
@@ -239,41 +325,62 @@ export function useSelect(
|
|
|
239
325
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
326
|
equalityFn: (a: any, b: any) => boolean = Object.is
|
|
241
327
|
): unknown {
|
|
328
|
+
// Use refs for selector and equalityFn so we always call the latest version
|
|
329
|
+
// without needing them as useMemo deps (which would recreate the observable).
|
|
330
|
+
const selectorRef = useRef(selector);
|
|
331
|
+
selectorRef.current = selector;
|
|
332
|
+
const equalityFnRef = useRef(equalityFn);
|
|
333
|
+
equalityFnRef.current = equalityFn;
|
|
334
|
+
|
|
335
|
+
// Stabilize the node identity across renders.
|
|
336
|
+
// Users pass inline arrays/objects like [store.a, store.b] or { a: store.a },
|
|
337
|
+
// which are new references each render. We extract the actual node references
|
|
338
|
+
// and only recreate the observable when the nodes themselves change.
|
|
339
|
+
const stableNodes = useStableNodes(nodeOrNodes);
|
|
340
|
+
|
|
242
341
|
// Determine the form and create the combined observable
|
|
243
342
|
const { combined$, getInitialValue } = useMemo(() => {
|
|
244
343
|
// Array form: [node1, node2, ...] - always requires selector
|
|
245
|
-
if (Array.isArray(
|
|
246
|
-
const nodes =
|
|
247
|
-
const sel = selector!; // selector is required for array form
|
|
344
|
+
if (Array.isArray(stableNodes)) {
|
|
345
|
+
const nodes = stableNodes as Observable<unknown>[];
|
|
248
346
|
return {
|
|
249
347
|
combined$: combineLatest(nodes).pipe(
|
|
250
|
-
|
|
251
|
-
|
|
348
|
+
// Deduplicate inputs so the selector only re-runs when an input actually changes.
|
|
349
|
+
// This prevents selectors that return new references (e.g. .sort(), .map())
|
|
350
|
+
// from causing infinite emission loops with the default Object.is equality.
|
|
351
|
+
distinctUntilChanged((a, b) =>
|
|
352
|
+
a.length === b.length && a.every((v, i) => Object.is(v, b[i]))
|
|
353
|
+
),
|
|
354
|
+
map((values) => selectorRef.current!(values)),
|
|
355
|
+
distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
|
|
252
356
|
),
|
|
253
357
|
getInitialValue: (): unknown => {
|
|
254
358
|
const values = nodes.map((n) => (hasGet<unknown>(n) ? n.get() : undefined));
|
|
255
|
-
return
|
|
359
|
+
return selectorRef.current!(values);
|
|
256
360
|
},
|
|
257
361
|
};
|
|
258
362
|
}
|
|
259
363
|
|
|
260
364
|
// Object form: { a: node1, b: node2, ... } - always requires selector
|
|
261
|
-
if (!isObservable(
|
|
262
|
-
const obj =
|
|
365
|
+
if (!isObservable(stableNodes)) {
|
|
366
|
+
const obj = stableNodes as Record<string, Observable<unknown>>;
|
|
263
367
|
const keys = Object.keys(obj);
|
|
264
368
|
const observables = keys.map((k) => obj[k]);
|
|
265
|
-
const sel = selector!; // selector is required for object form
|
|
266
369
|
|
|
267
370
|
return {
|
|
268
371
|
combined$: combineLatest(observables).pipe(
|
|
372
|
+
// Deduplicate inputs so the selector only re-runs when an input actually changes.
|
|
373
|
+
distinctUntilChanged((a, b) =>
|
|
374
|
+
a.length === b.length && a.every((v, i) => Object.is(v, b[i]))
|
|
375
|
+
),
|
|
269
376
|
map((values) => {
|
|
270
377
|
const result: Record<string, unknown> = {};
|
|
271
378
|
keys.forEach((key, i) => {
|
|
272
379
|
result[key] = values[i];
|
|
273
380
|
});
|
|
274
|
-
return
|
|
381
|
+
return selectorRef.current!(result);
|
|
275
382
|
}),
|
|
276
|
-
distinctUntilChanged(
|
|
383
|
+
distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
|
|
277
384
|
),
|
|
278
385
|
getInitialValue: (): unknown => {
|
|
279
386
|
const result: Record<string, unknown> = {};
|
|
@@ -281,24 +388,26 @@ export function useSelect(
|
|
|
281
388
|
const node = obj[key];
|
|
282
389
|
result[key] = hasGet<unknown>(node) ? node.get() : undefined;
|
|
283
390
|
});
|
|
284
|
-
return
|
|
391
|
+
return selectorRef.current!(result);
|
|
285
392
|
},
|
|
286
393
|
};
|
|
287
394
|
}
|
|
288
395
|
|
|
289
396
|
// Single node form - selector is optional
|
|
290
|
-
const node =
|
|
291
|
-
|
|
292
|
-
if (
|
|
397
|
+
const node = stableNodes as Observable<unknown>;
|
|
398
|
+
|
|
399
|
+
if (selectorRef.current) {
|
|
293
400
|
// With selector - apply transformation
|
|
294
401
|
return {
|
|
295
402
|
combined$: node.pipe(
|
|
296
|
-
|
|
297
|
-
distinctUntilChanged(
|
|
403
|
+
// Deduplicate inputs so the selector only re-runs when the input actually changes.
|
|
404
|
+
distinctUntilChanged(),
|
|
405
|
+
map((value) => selectorRef.current!(value)),
|
|
406
|
+
distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
|
|
298
407
|
),
|
|
299
408
|
getInitialValue: (): unknown => {
|
|
300
409
|
if (hasGet<unknown>(node)) {
|
|
301
|
-
return
|
|
410
|
+
return selectorRef.current!(node.get());
|
|
302
411
|
}
|
|
303
412
|
return undefined;
|
|
304
413
|
},
|
|
@@ -307,7 +416,7 @@ export function useSelect(
|
|
|
307
416
|
// No selector - return raw value
|
|
308
417
|
return {
|
|
309
418
|
combined$: node.pipe(
|
|
310
|
-
distinctUntilChanged(
|
|
419
|
+
distinctUntilChanged((a, b) => equalityFnRef.current(a, b))
|
|
311
420
|
),
|
|
312
421
|
getInitialValue: (): unknown => {
|
|
313
422
|
if (hasGet<unknown>(node)) {
|
|
@@ -317,7 +426,8 @@ export function useSelect(
|
|
|
317
426
|
},
|
|
318
427
|
};
|
|
319
428
|
}
|
|
320
|
-
|
|
429
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
430
|
+
}, [stableNodes]);
|
|
321
431
|
|
|
322
432
|
// Ref to hold the current derived value
|
|
323
433
|
const valueRef = useRef<unknown>(getInitialValue());
|