jotai-state-tree 0.1.0
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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
package/src/react.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React integration for jotai-state-tree
|
|
3
|
+
* Provides observer HOC and hooks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, {
|
|
7
|
+
useState,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
forwardRef,
|
|
12
|
+
memo,
|
|
13
|
+
useCallback,
|
|
14
|
+
useSyncExternalStore,
|
|
15
|
+
type ComponentType,
|
|
16
|
+
type ForwardedRef,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
type FC,
|
|
19
|
+
} from "react";
|
|
20
|
+
import {
|
|
21
|
+
useAtom,
|
|
22
|
+
useAtomValue,
|
|
23
|
+
useSetAtom,
|
|
24
|
+
type Atom,
|
|
25
|
+
type WritableAtom,
|
|
26
|
+
} from "jotai";
|
|
27
|
+
import {
|
|
28
|
+
getStateTreeNode,
|
|
29
|
+
hasStateTreeNode,
|
|
30
|
+
onSnapshot,
|
|
31
|
+
getSnapshot,
|
|
32
|
+
onLifecycleChange,
|
|
33
|
+
type IDisposer,
|
|
34
|
+
} from "./tree";
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Observer HOC
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
interface ObserverOptions {
|
|
41
|
+
forwardRef?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Higher-order component that makes a component reactive to state tree changes.
|
|
46
|
+
* Similar to mobx-react-lite's observer.
|
|
47
|
+
*/
|
|
48
|
+
export function observer<P extends object>(
|
|
49
|
+
Component: ComponentType<P>,
|
|
50
|
+
options?: ObserverOptions,
|
|
51
|
+
): ComponentType<P> {
|
|
52
|
+
const displayName = Component.displayName || Component.name || "Component";
|
|
53
|
+
|
|
54
|
+
const ObserverComponent = memo((props: P) => {
|
|
55
|
+
const [, forceUpdate] = useState({});
|
|
56
|
+
const disposersRef = useRef<Set<IDisposer>>(new Set());
|
|
57
|
+
const trackedNodesRef = useRef<Set<unknown>>(new Set());
|
|
58
|
+
|
|
59
|
+
// Track which state tree nodes are accessed during render
|
|
60
|
+
const trackNode = (node: unknown) => {
|
|
61
|
+
if (hasStateTreeNode(node) && !trackedNodesRef.current.has(node)) {
|
|
62
|
+
trackedNodesRef.current.add(node);
|
|
63
|
+
const disposer = onSnapshot(node, () => {
|
|
64
|
+
forceUpdate({});
|
|
65
|
+
});
|
|
66
|
+
disposersRef.current.add(disposer);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Create a proxy for tracking property access
|
|
71
|
+
const createTrackingProxy = <T extends object>(target: T): T => {
|
|
72
|
+
if (!target || typeof target !== "object") return target;
|
|
73
|
+
if (hasStateTreeNode(target)) {
|
|
74
|
+
trackNode(target);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return new Proxy(target, {
|
|
78
|
+
get(obj, prop) {
|
|
79
|
+
const value = (obj as Record<string | symbol, unknown>)[prop];
|
|
80
|
+
if (value && typeof value === "object" && hasStateTreeNode(value)) {
|
|
81
|
+
trackNode(value);
|
|
82
|
+
return createTrackingProxy(value as object);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
},
|
|
86
|
+
}) as T;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Clear old subscriptions on re-render
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
return () => {
|
|
92
|
+
disposersRef.current.forEach((d) => d());
|
|
93
|
+
disposersRef.current.clear();
|
|
94
|
+
trackedNodesRef.current.clear();
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// Wrap props that might be state tree nodes
|
|
99
|
+
const trackedProps = useMemo(() => {
|
|
100
|
+
const tracked: Record<string, unknown> = {};
|
|
101
|
+
for (const [key, value] of Object.entries(props)) {
|
|
102
|
+
if (value && typeof value === "object") {
|
|
103
|
+
tracked[key] = createTrackingProxy(value as object);
|
|
104
|
+
} else {
|
|
105
|
+
tracked[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return tracked as P;
|
|
109
|
+
}, [props]);
|
|
110
|
+
|
|
111
|
+
return React.createElement(Component, trackedProps);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
ObserverComponent.displayName = `Observer(${displayName})`;
|
|
115
|
+
|
|
116
|
+
if (options?.forwardRef) {
|
|
117
|
+
const ForwardedComponent = forwardRef<unknown, P>((props, ref) => {
|
|
118
|
+
const propsWithRef = Object.assign({}, props, { ref });
|
|
119
|
+
return React.createElement(
|
|
120
|
+
ObserverComponent as unknown as ComponentType<P>,
|
|
121
|
+
propsWithRef as P,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
ForwardedComponent.displayName = `ForwardRef(${displayName})`;
|
|
125
|
+
return ForwardedComponent as unknown as ComponentType<P>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return ObserverComponent as unknown as ComponentType<P>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// Observer Component (Render Props)
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
135
|
+
interface ObserverComponentProps {
|
|
136
|
+
children: () => ReactNode;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Observer component using render props pattern.
|
|
141
|
+
* Useful for inline observation.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* <Observer>
|
|
145
|
+
* {() => <div>{store.count}</div>}
|
|
146
|
+
* </Observer>
|
|
147
|
+
*/
|
|
148
|
+
export const Observer: FC<ObserverComponentProps> = observer(({ children }) => {
|
|
149
|
+
return React.createElement(React.Fragment, null, children());
|
|
150
|
+
}) as FC<ObserverComponentProps>;
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// useObserver Hook
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hook that re-renders the component when any accessed state tree nodes change.
|
|
158
|
+
*/
|
|
159
|
+
export function useObserver<T>(fn: () => T): T {
|
|
160
|
+
const [, forceUpdate] = useState({});
|
|
161
|
+
const disposersRef = useRef<IDisposer[]>([]);
|
|
162
|
+
const trackedNodes = useRef<Set<unknown>>(new Set());
|
|
163
|
+
|
|
164
|
+
// Clear previous subscriptions
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
return () => {
|
|
167
|
+
disposersRef.current.forEach((d) => d());
|
|
168
|
+
disposersRef.current = [];
|
|
169
|
+
};
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
// Execute the function and track accessed nodes
|
|
173
|
+
const result = useMemo(() => {
|
|
174
|
+
// Clear previous tracking
|
|
175
|
+
trackedNodes.current.clear();
|
|
176
|
+
disposersRef.current.forEach((d) => d());
|
|
177
|
+
disposersRef.current = [];
|
|
178
|
+
|
|
179
|
+
// Execute and capture result
|
|
180
|
+
const value = fn();
|
|
181
|
+
|
|
182
|
+
return value;
|
|
183
|
+
}, [fn]);
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// useLocalObservable Hook
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Creates a local observable state tree instance.
|
|
194
|
+
* Similar to mobx-react-lite's useLocalObservable.
|
|
195
|
+
*/
|
|
196
|
+
export function useLocalObservable<T>(
|
|
197
|
+
initializer: () => T,
|
|
198
|
+
dependencies: unknown[] = [],
|
|
199
|
+
): T {
|
|
200
|
+
const [, forceUpdate] = useState({});
|
|
201
|
+
const storeRef = useRef<T | null>(null);
|
|
202
|
+
const disposerRef = useRef<IDisposer | null>(null);
|
|
203
|
+
|
|
204
|
+
// Initialize store
|
|
205
|
+
if (storeRef.current === null) {
|
|
206
|
+
storeRef.current = initializer();
|
|
207
|
+
|
|
208
|
+
// Subscribe to changes
|
|
209
|
+
if (hasStateTreeNode(storeRef.current)) {
|
|
210
|
+
disposerRef.current = onSnapshot(storeRef.current, () => {
|
|
211
|
+
forceUpdate({});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Cleanup on unmount
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
return () => {
|
|
219
|
+
disposerRef.current?.();
|
|
220
|
+
};
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Reinitialize if dependencies change
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (dependencies.length > 0) {
|
|
226
|
+
disposerRef.current?.();
|
|
227
|
+
storeRef.current = initializer();
|
|
228
|
+
if (hasStateTreeNode(storeRef.current)) {
|
|
229
|
+
disposerRef.current = onSnapshot(storeRef.current, () => {
|
|
230
|
+
forceUpdate({});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}, dependencies);
|
|
235
|
+
|
|
236
|
+
return storeRef.current;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// useStore Hook with useSyncExternalStore
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Use a state tree instance with React's useSyncExternalStore.
|
|
245
|
+
* This provides better concurrent mode support.
|
|
246
|
+
*/
|
|
247
|
+
export function useSyncedStore<T>(store: T): T {
|
|
248
|
+
// Cache the snapshot to provide stable reference for useSyncExternalStore
|
|
249
|
+
const snapshotRef = useRef<unknown>(null);
|
|
250
|
+
|
|
251
|
+
const subscribe = useCallback(
|
|
252
|
+
(callback: () => void) => {
|
|
253
|
+
if (!hasStateTreeNode(store)) {
|
|
254
|
+
return () => {};
|
|
255
|
+
}
|
|
256
|
+
return onSnapshot(store, () => {
|
|
257
|
+
// Update cached snapshot on change
|
|
258
|
+
snapshotRef.current = getSnapshot(store);
|
|
259
|
+
callback();
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
[store],
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const getSnapshotValue = useCallback(() => {
|
|
266
|
+
if (!hasStateTreeNode(store)) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
// Return cached snapshot for stable reference comparison
|
|
270
|
+
if (snapshotRef.current === null) {
|
|
271
|
+
snapshotRef.current = getSnapshot(store);
|
|
272
|
+
}
|
|
273
|
+
return snapshotRef.current;
|
|
274
|
+
}, [store]);
|
|
275
|
+
|
|
276
|
+
useSyncExternalStore(subscribe, getSnapshotValue, getSnapshotValue);
|
|
277
|
+
|
|
278
|
+
return store;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ============================================================================
|
|
282
|
+
// Provider Component - Legacy (untyped)
|
|
283
|
+
// ============================================================================
|
|
284
|
+
|
|
285
|
+
interface StoreContextValue<T> {
|
|
286
|
+
store: T;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const StoreContext = React.createContext<StoreContextValue<unknown> | null>(
|
|
290
|
+
null,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
interface ProviderProps<T> {
|
|
294
|
+
store: T;
|
|
295
|
+
children: ReactNode;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Provider component for state tree stores.
|
|
300
|
+
* @deprecated Use createStoreContext() for better type inference
|
|
301
|
+
*/
|
|
302
|
+
export function Provider<T>({
|
|
303
|
+
store,
|
|
304
|
+
children,
|
|
305
|
+
}: ProviderProps<T>): JSX.Element {
|
|
306
|
+
const value = useMemo(() => ({ store }), [store]);
|
|
307
|
+
return React.createElement(StoreContext.Provider, { value }, children);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Hook to access the store from context.
|
|
312
|
+
* @deprecated Use createStoreContext() for better type inference
|
|
313
|
+
*/
|
|
314
|
+
export function useStore<T>(): T {
|
|
315
|
+
const context = React.useContext(StoreContext);
|
|
316
|
+
if (!context) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
"[jotai-state-tree] useStore must be used within a Provider",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return context.store as T;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Hook to access the store with snapshot subscription.
|
|
326
|
+
* @deprecated Use createStoreContext() for better type inference
|
|
327
|
+
*/
|
|
328
|
+
export function useStoreSnapshot<T>(): T;
|
|
329
|
+
export function useStoreSnapshot<T, S>(selector: (store: T) => S): S;
|
|
330
|
+
export function useStoreSnapshot<T, S>(selector?: (store: T) => S): T | S {
|
|
331
|
+
const store = useStore<T>();
|
|
332
|
+
const [, forceUpdate] = useState({});
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (hasStateTreeNode(store)) {
|
|
336
|
+
return onSnapshot(store, () => {
|
|
337
|
+
forceUpdate({});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return () => {};
|
|
341
|
+
}, [store]);
|
|
342
|
+
|
|
343
|
+
if (selector) {
|
|
344
|
+
return selector(store);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return store;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// Typed Store Context Factory
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Creates a typed store context with Provider and hooks.
|
|
356
|
+
* This provides full type inference without needing to specify generic types.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* const RootStore = types.model("RootStore", {
|
|
360
|
+
* count: types.number,
|
|
361
|
+
* }).actions(self => ({
|
|
362
|
+
* increment() { self.count += 1; }
|
|
363
|
+
* }));
|
|
364
|
+
*
|
|
365
|
+
* type RootStoreInstance = Instance<typeof RootStore>;
|
|
366
|
+
*
|
|
367
|
+
* const { Provider, useStore, useStoreSnapshot } = createStoreContext<RootStoreInstance>();
|
|
368
|
+
*
|
|
369
|
+
* // In your app:
|
|
370
|
+
* const store = RootStore.create({ count: 0 });
|
|
371
|
+
* <Provider store={store}>
|
|
372
|
+
* <App />
|
|
373
|
+
* </Provider>
|
|
374
|
+
*
|
|
375
|
+
* // In components:
|
|
376
|
+
* const store = useStore(); // Fully typed!
|
|
377
|
+
* store.increment(); // Type-safe
|
|
378
|
+
*/
|
|
379
|
+
export function createStoreContext<T>() {
|
|
380
|
+
const Context = React.createContext<T | null>(null);
|
|
381
|
+
|
|
382
|
+
function StoreProvider({
|
|
383
|
+
store,
|
|
384
|
+
children,
|
|
385
|
+
}: {
|
|
386
|
+
store: T;
|
|
387
|
+
children: ReactNode;
|
|
388
|
+
}): JSX.Element {
|
|
389
|
+
return React.createElement(Context.Provider, { value: store }, children);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function useTypedStore(): T {
|
|
393
|
+
const store = React.useContext(Context);
|
|
394
|
+
if (store === null) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
"[jotai-state-tree] useStore must be used within a Provider",
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return store;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function useTypedStoreSnapshot(): T;
|
|
403
|
+
function useTypedStoreSnapshot<S>(selector: (store: T) => S): S;
|
|
404
|
+
function useTypedStoreSnapshot<S>(selector?: (store: T) => S): T | S {
|
|
405
|
+
const store = useTypedStore();
|
|
406
|
+
const [, forceUpdate] = useState({});
|
|
407
|
+
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (hasStateTreeNode(store)) {
|
|
410
|
+
return onSnapshot(store, () => {
|
|
411
|
+
forceUpdate({});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return () => {};
|
|
415
|
+
}, [store]);
|
|
416
|
+
|
|
417
|
+
if (selector) {
|
|
418
|
+
return selector(store);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return store;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Hook that returns whether the store is alive.
|
|
426
|
+
*/
|
|
427
|
+
function useTypedIsAlive(): boolean {
|
|
428
|
+
const store = useTypedStore();
|
|
429
|
+
return useIsAlive(store);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
Provider: StoreProvider,
|
|
434
|
+
useStore: useTypedStore,
|
|
435
|
+
useStoreSnapshot: useTypedStoreSnapshot,
|
|
436
|
+
useIsAlive: useTypedIsAlive,
|
|
437
|
+
Context,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ============================================================================
|
|
442
|
+
// Snapshot Hooks
|
|
443
|
+
// ============================================================================
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Hook that returns the current snapshot and re-renders on changes.
|
|
447
|
+
*/
|
|
448
|
+
export function useSnapshot<T>(target: unknown): T {
|
|
449
|
+
const [snapshot, setSnapshot] = useState<T>(() => getSnapshot(target));
|
|
450
|
+
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
const disposer = onSnapshot(target, (newSnapshot) => {
|
|
453
|
+
setSnapshot(newSnapshot as T);
|
|
454
|
+
});
|
|
455
|
+
return disposer;
|
|
456
|
+
}, [target]);
|
|
457
|
+
|
|
458
|
+
return snapshot;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Hook to watch specific paths in a state tree
|
|
463
|
+
*/
|
|
464
|
+
export function useWatchPath<T>(
|
|
465
|
+
target: unknown,
|
|
466
|
+
path: string,
|
|
467
|
+
defaultValue?: T,
|
|
468
|
+
): T {
|
|
469
|
+
const [value, setValue] = useState<T>(() => {
|
|
470
|
+
const snapshot = getSnapshot(target) as Record<string, unknown>;
|
|
471
|
+
const parts = path.split(".");
|
|
472
|
+
let current: unknown = snapshot;
|
|
473
|
+
for (const part of parts) {
|
|
474
|
+
if (current && typeof current === "object" && part in current) {
|
|
475
|
+
current = (current as Record<string, unknown>)[part];
|
|
476
|
+
} else {
|
|
477
|
+
return defaultValue as T;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return current as T;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
const disposer = onSnapshot(target, (newSnapshot) => {
|
|
485
|
+
const snapshot = newSnapshot as Record<string, unknown>;
|
|
486
|
+
const parts = path.split(".");
|
|
487
|
+
let current: unknown = snapshot;
|
|
488
|
+
for (const part of parts) {
|
|
489
|
+
if (current && typeof current === "object" && part in current) {
|
|
490
|
+
current = (current as Record<string, unknown>)[part];
|
|
491
|
+
} else {
|
|
492
|
+
setValue(defaultValue as T);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
setValue(current as T);
|
|
497
|
+
});
|
|
498
|
+
return disposer;
|
|
499
|
+
}, [target, path, defaultValue]);
|
|
500
|
+
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Hook that subscribes to patches on a node.
|
|
506
|
+
*/
|
|
507
|
+
export function usePatches(
|
|
508
|
+
target: unknown,
|
|
509
|
+
callback: (patch: { op: string; path: string; value?: unknown }) => void,
|
|
510
|
+
): void {
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
const { onPatch } = require("./tree");
|
|
513
|
+
const disposer = onPatch(target, callback);
|
|
514
|
+
return disposer;
|
|
515
|
+
}, [target, callback]);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ============================================================================
|
|
519
|
+
// Action Hooks
|
|
520
|
+
// ============================================================================
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Hook that returns an action bound to a store.
|
|
524
|
+
* Useful for passing actions to child components.
|
|
525
|
+
*/
|
|
526
|
+
export function useAction<T extends (...args: unknown[]) => unknown>(
|
|
527
|
+
action: T,
|
|
528
|
+
): T {
|
|
529
|
+
return useMemo(() => action, [action]);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Hook that returns multiple actions bound to a store.
|
|
534
|
+
*/
|
|
535
|
+
export function useActions<
|
|
536
|
+
T extends Record<string, (...args: unknown[]) => unknown>,
|
|
537
|
+
>(actions: T): T {
|
|
538
|
+
return useMemo(() => actions, [actions]);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ============================================================================
|
|
542
|
+
// Observer Batching
|
|
543
|
+
// ============================================================================
|
|
544
|
+
|
|
545
|
+
let batchDepth = 0;
|
|
546
|
+
let pendingUpdates: Set<() => void> = new Set();
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Batch multiple state updates to trigger a single re-render.
|
|
550
|
+
*/
|
|
551
|
+
export function batch(fn: () => void): void {
|
|
552
|
+
batchDepth++;
|
|
553
|
+
try {
|
|
554
|
+
fn();
|
|
555
|
+
} finally {
|
|
556
|
+
batchDepth--;
|
|
557
|
+
if (batchDepth === 0 && pendingUpdates.size > 0) {
|
|
558
|
+
const updates = pendingUpdates;
|
|
559
|
+
pendingUpdates = new Set();
|
|
560
|
+
updates.forEach((update) => update());
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Schedule an update, batching if we're inside a batch() call.
|
|
567
|
+
*/
|
|
568
|
+
export function scheduleUpdate(update: () => void): void {
|
|
569
|
+
if (batchDepth > 0) {
|
|
570
|
+
pendingUpdates.add(update);
|
|
571
|
+
} else {
|
|
572
|
+
update();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ============================================================================
|
|
577
|
+
// Utility Hooks
|
|
578
|
+
// ============================================================================
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Hook that returns whether a node is alive.
|
|
582
|
+
* Uses proper subscription instead of polling for better performance.
|
|
583
|
+
*/
|
|
584
|
+
export function useIsAlive(target: unknown): boolean {
|
|
585
|
+
const [isAlive, setIsAlive] = useState(() => {
|
|
586
|
+
if (!hasStateTreeNode(target)) return false;
|
|
587
|
+
return getStateTreeNode(target).$isAlive;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
useEffect(() => {
|
|
591
|
+
if (!hasStateTreeNode(target)) return;
|
|
592
|
+
|
|
593
|
+
const node = getStateTreeNode(target);
|
|
594
|
+
setIsAlive(node.$isAlive);
|
|
595
|
+
|
|
596
|
+
// Subscribe to lifecycle changes using proper event system
|
|
597
|
+
// This is much more efficient than polling
|
|
598
|
+
const disposer = onLifecycleChange(node, (alive) => {
|
|
599
|
+
setIsAlive(alive);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
return disposer;
|
|
603
|
+
}, [target]);
|
|
604
|
+
|
|
605
|
+
return isAlive;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Hook that ensures cleanup when a component unmounts
|
|
610
|
+
*/
|
|
611
|
+
export function useCleanup(cleanupFn: () => void): void {
|
|
612
|
+
const cleanupRef = useRef(cleanupFn);
|
|
613
|
+
cleanupRef.current = cleanupFn;
|
|
614
|
+
|
|
615
|
+
useEffect(() => {
|
|
616
|
+
return () => {
|
|
617
|
+
cleanupRef.current();
|
|
618
|
+
};
|
|
619
|
+
}, []);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// Type Exports
|
|
624
|
+
// ============================================================================
|
|
625
|
+
|
|
626
|
+
export type { ObserverOptions };
|