@kuindji/reactive 1.1.0 → 1.2.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/README.md +128 -5
- package/dist/action.d.ts +19 -0
- package/dist/action.js +137 -14
- package/dist/actionBus.d.ts +5 -0
- package/dist/actionBus.js +168 -4
- package/dist/actionMap.d.ts +5 -0
- package/dist/event.d.ts +34 -1
- package/dist/event.js +256 -43
- package/dist/eventBus.d.ts +2 -0
- package/dist/eventBus.js +106 -3
- package/dist/lib/types.d.ts +1 -1
- package/dist/react/useActionBusStatus.d.ts +13 -0
- package/dist/react/useActionBusStatus.js +26 -0
- package/dist/react/useAsyncAction.d.ts +20 -0
- package/dist/react/useAsyncAction.js +53 -0
- package/dist/react/useStoreSelector.d.ts +35 -0
- package/dist/react/useStoreSelector.js +144 -0
- package/dist/react/useStoreState.js +13 -1
- package/dist/react.d.ts +3 -0
- package/dist/react.js +3 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.js +410 -43
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Subscribes to the status of a named action on an ActionBus and returns
|
|
4
|
+
* `{ loading, error, response }` for driving `loading`/`disabled` UI. This is
|
|
5
|
+
* the primary path for apps that route mutations through one shared ActionBus.
|
|
6
|
+
*
|
|
7
|
+
* An unregistered name reports an idle status and is safe to subscribe to.
|
|
8
|
+
*/
|
|
9
|
+
export function useActionBusStatus(bus, name) {
|
|
10
|
+
const subscribe = useCallback((onChange) => {
|
|
11
|
+
const listener = () => {
|
|
12
|
+
onChange();
|
|
13
|
+
};
|
|
14
|
+
bus.onStatusChange(name, listener);
|
|
15
|
+
return () => {
|
|
16
|
+
bus.removeStatusListener(name, listener);
|
|
17
|
+
};
|
|
18
|
+
}, [bus, name]);
|
|
19
|
+
const getSnapshot = useCallback(() => bus.getStatus(name), [bus, name]);
|
|
20
|
+
const status = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
21
|
+
return {
|
|
22
|
+
loading: status.pending,
|
|
23
|
+
error: status.error,
|
|
24
|
+
response: status.response,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ActionResponse, ActionStatus } from "../action.js";
|
|
2
|
+
import type { BaseHandler } from "../lib/types.js";
|
|
3
|
+
export type { ActionResponse, ActionStatus };
|
|
4
|
+
export type AsyncActionState<Response = any> = {
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: Error | null;
|
|
7
|
+
response: Response | null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Wraps a function in an action and exposes its in-flight status, so a
|
|
11
|
+
* component can drive `loading`/`disabled` without a hand-rolled
|
|
12
|
+
* `useState(false)`. Returns `[invoke, { loading, error, response }]`.
|
|
13
|
+
*
|
|
14
|
+
* For the common app pattern (one shared ActionBus) prefer
|
|
15
|
+
* `useActionBusStatus`. This hook is for a standalone, component-local action.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useAsyncAction<Fn extends BaseHandler>(fn: Fn): readonly [
|
|
18
|
+
(...args: Parameters<Fn>) => Promise<ActionResponse<Awaited<ReturnType<Fn>>, Parameters<Fn>>>,
|
|
19
|
+
AsyncActionState<Awaited<ReturnType<Fn>>>
|
|
20
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
|
|
2
|
+
import { createAction } from "../action.js";
|
|
3
|
+
/**
|
|
4
|
+
* Wraps a function in an action and exposes its in-flight status, so a
|
|
5
|
+
* component can drive `loading`/`disabled` without a hand-rolled
|
|
6
|
+
* `useState(false)`. Returns `[invoke, { loading, error, response }]`.
|
|
7
|
+
*
|
|
8
|
+
* For the common app pattern (one shared ActionBus) prefer
|
|
9
|
+
* `useActionBusStatus`. This hook is for a standalone, component-local action.
|
|
10
|
+
*/
|
|
11
|
+
export function useAsyncAction(fn) {
|
|
12
|
+
// Keep the latest fn in a ref. The action wraps a stable indirection that
|
|
13
|
+
// always calls fnRef.current, so it invokes the current fn even from a
|
|
14
|
+
// consumer layout effect that runs after a rerender but before this hook's
|
|
15
|
+
// passive effects — which a useEffect+setAction swap would miss, invoking
|
|
16
|
+
// the previous fn. The ref is updated in a layout effect (commit phase),
|
|
17
|
+
// not during render: a render-phase mutation would leak a fn from a
|
|
18
|
+
// suspended or abandoned concurrent render that never commits into the
|
|
19
|
+
// currently committed UI. Layout effects run only for committed renders,
|
|
20
|
+
// and this one runs before any consumer layout effect declared after the
|
|
21
|
+
// hook call, so consumers still observe the latest fn.
|
|
22
|
+
const fnRef = useRef(fn);
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
fnRef.current = fn;
|
|
25
|
+
});
|
|
26
|
+
const action = useMemo(() => {
|
|
27
|
+
const action = createAction(((...args) => fnRef.current(...args)));
|
|
28
|
+
// Without an error listener a throwing fn re-throws out of invoke
|
|
29
|
+
// (an unhandled rejection) instead of surfacing through status.
|
|
30
|
+
action.addErrorListener(() => { });
|
|
31
|
+
return action;
|
|
32
|
+
}, []);
|
|
33
|
+
const subscribe = useCallback((onChange) => {
|
|
34
|
+
const listener = () => {
|
|
35
|
+
onChange();
|
|
36
|
+
};
|
|
37
|
+
action.onStatusChange(listener);
|
|
38
|
+
return () => {
|
|
39
|
+
action.removeStatusListener(listener);
|
|
40
|
+
};
|
|
41
|
+
}, [action]);
|
|
42
|
+
const getSnapshot = useCallback(() => action.getStatus(), [action]);
|
|
43
|
+
const status = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
44
|
+
const invoke = useCallback((...args) => action.invoke(...args), [action]);
|
|
45
|
+
return [
|
|
46
|
+
invoke,
|
|
47
|
+
{
|
|
48
|
+
loading: status.pending,
|
|
49
|
+
error: status.error,
|
|
50
|
+
response: status.response,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { KeyOf } from "../lib/types.js";
|
|
2
|
+
import type { BaseStore } from "../store.js";
|
|
3
|
+
export type EqualityFn<T> = (a: T, b: T) => boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Subscribes to a derived slice of a store with custom equality. Selector
|
|
6
|
+
* re-execution is gated on the raw input (dep values, or a shallow compare of
|
|
7
|
+
* the full state), so a selector that builds a fresh object each call returns a
|
|
8
|
+
* stable cached reference while its input is unchanged — safe even without an
|
|
9
|
+
* equality fn (an un-gated fresh reference on every getSnapshot call would loop
|
|
10
|
+
* forever). The optional equality fn additionally bails React re-renders when a
|
|
11
|
+
* recompute produces an equal-but-fresh result.
|
|
12
|
+
*
|
|
13
|
+
* Two forms:
|
|
14
|
+
* useStoreSelector(store, (s) => `${s.first} ${s.last}`, shallowEqual?)
|
|
15
|
+
* useStoreSelector(store, ["first", "last"], (first, last) => …, eqFn?)
|
|
16
|
+
*
|
|
17
|
+
* The deps-keyed form recomputes only when the change batch touches its keys.
|
|
18
|
+
*
|
|
19
|
+
* Prefer the deps-keyed form for narrow reads. The selector form (no deps)
|
|
20
|
+
* subscribes to every store change and rebuilds the whole state via getData()
|
|
21
|
+
* on each one, re-running the selector even for unrelated writes (the equality
|
|
22
|
+
* fn still bails React re-renders, but the recompute itself is not filtered).
|
|
23
|
+
* The deps-keyed form both filters the subscription to its keys and avoids
|
|
24
|
+
* materializing the full state, so reach for it when selecting a few slices of
|
|
25
|
+
* a large or frequently-written store.
|
|
26
|
+
*
|
|
27
|
+
* Concurrent-safe: the selection is memoized in a render-phase `useMemo` (an
|
|
28
|
+
* abandoned concurrent render discards it rather than leaking it into the
|
|
29
|
+
* committed tree) and the committed value is recorded in an effect, not during
|
|
30
|
+
* render. This mirrors React's own `useSyncExternalStoreWithSelector`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function useStoreSelector<TStore extends BaseStore, R>(store: TStore, selector: (state: TStore["__type"]["propTypes"]) => R, equalityFn?: EqualityFn<R>): R;
|
|
33
|
+
export declare function useStoreSelector<TStore extends BaseStore, const D extends readonly KeyOf<TStore["__type"]["propTypes"]>[], R>(store: TStore, deps: D, selector: (...values: {
|
|
34
|
+
[I in keyof D]: TStore["__type"]["propTypes"][D[I]];
|
|
35
|
+
}) => R, equalityFn?: EqualityFn<R>): R;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
|
|
2
|
+
import { ChangeEventName } from "../store.js";
|
|
3
|
+
// Shallow per-entry equality for two state objects (enumerable own keys
|
|
4
|
+
// compared with Object.is). Used to gate selector re-execution in the
|
|
5
|
+
// no-deps form, whose input is rebuilt fresh by getData() on every call.
|
|
6
|
+
function shallowEqualObject(a, b) {
|
|
7
|
+
if (a === b) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
const aKeys = Object.keys(a);
|
|
11
|
+
const bKeys = Object.keys(b);
|
|
12
|
+
if (aKeys.length !== bKeys.length) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
for (const key of aKeys) {
|
|
16
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)
|
|
17
|
+
|| !Object.is(a[key], b[key])) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
export function useStoreSelector(store, arg2, arg3, arg4) {
|
|
24
|
+
var _a;
|
|
25
|
+
const deps = Array.isArray(arg2) ? arg2 : null;
|
|
26
|
+
const selector = (deps ? arg3 : arg2);
|
|
27
|
+
const equalityFn = (_a = (deps ? arg4 : arg3)) !== null && _a !== void 0 ? _a : Object.is;
|
|
28
|
+
// Committed selection cache. Written ONLY in an effect (commit phase) so an
|
|
29
|
+
// abandoned concurrent render cannot leak its selection into the committed
|
|
30
|
+
// tree (which a render-phase write to this cache would).
|
|
31
|
+
const instRef = useRef(null);
|
|
32
|
+
if (instRef.current === null) {
|
|
33
|
+
instRef.current = { hasValue: false, value: null };
|
|
34
|
+
}
|
|
35
|
+
const inst = instRef.current;
|
|
36
|
+
// Latest deps for the subscribe filter. Updated in a layout effect (commit
|
|
37
|
+
// phase), not during render, and read only inside the change listener (which
|
|
38
|
+
// fires after commit), so the subscription always filters on committed deps.
|
|
39
|
+
const depsRef = useRef(deps);
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
depsRef.current = deps;
|
|
42
|
+
});
|
|
43
|
+
// The memoized selection getter. Built during render, but the useMemo result
|
|
44
|
+
// is part of the fiber's memoized state: an abandoned concurrent render
|
|
45
|
+
// discards it, so no closure leaks. Rebuilt only when the store, selector,
|
|
46
|
+
// equality, or deps identity changes. The committed `inst.value` is read
|
|
47
|
+
// (never written) here, so a re-render with an equal result bails out to the
|
|
48
|
+
// committed reference.
|
|
49
|
+
const getSelection = useMemo(() => {
|
|
50
|
+
let hasMemo = false;
|
|
51
|
+
let memoizedInput = [];
|
|
52
|
+
let memoized;
|
|
53
|
+
// Read the raw selector input (dep values, or the full state). On a
|
|
54
|
+
// destroyed store, read via getData() (which returns {} without
|
|
55
|
+
// asserting) instead of store.get() (which throws): getSnapshot can
|
|
56
|
+
// run for a still-mounted component after the store is destroyed
|
|
57
|
+
// (e.g. a provider torn down first), and must not throw out of
|
|
58
|
+
// render. Returns the input as an arg array so it can be both
|
|
59
|
+
// shallow-compared and spread into the selector.
|
|
60
|
+
const readInput = () => {
|
|
61
|
+
if (deps) {
|
|
62
|
+
if (store.isDestroyed()) {
|
|
63
|
+
const snapshot = store.getData();
|
|
64
|
+
return deps.map((d) => snapshot[d]);
|
|
65
|
+
}
|
|
66
|
+
return deps.map((d) => store.get(d));
|
|
67
|
+
}
|
|
68
|
+
return [store.getData()];
|
|
69
|
+
};
|
|
70
|
+
// Compare two raw inputs. The deps form holds dep values, compared
|
|
71
|
+
// by identity (the store replaces values on change, so identity
|
|
72
|
+
// tracks change). The selector form holds a single full-state
|
|
73
|
+
// object rebuilt fresh by getData() on every call, so it must be
|
|
74
|
+
// shallow-compared by entries rather than reference.
|
|
75
|
+
const inputsEqual = (a, b) => {
|
|
76
|
+
if (deps) {
|
|
77
|
+
if (a.length !== b.length) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
for (let i = 0; i < a.length; i++) {
|
|
81
|
+
if (!Object.is(a[i], b[i])) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return shallowEqualObject(a[0], b[0]);
|
|
88
|
+
};
|
|
89
|
+
return () => {
|
|
90
|
+
const input = readInput();
|
|
91
|
+
if (!hasMemo) {
|
|
92
|
+
hasMemo = true;
|
|
93
|
+
memoizedInput = input;
|
|
94
|
+
const next = selector(...input);
|
|
95
|
+
if (inst.hasValue && equalityFn(inst.value, next)) {
|
|
96
|
+
memoized = inst.value;
|
|
97
|
+
return inst.value;
|
|
98
|
+
}
|
|
99
|
+
memoized = next;
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
// Gate selector re-execution on the raw input. getSnapshot must
|
|
103
|
+
// return a cached reference that only changes when the store
|
|
104
|
+
// changes; re-running a fresh-object selector on every call (and
|
|
105
|
+
// relying solely on equalityFn) would return a new reference
|
|
106
|
+
// each call under the default Object.is and loop forever. When
|
|
107
|
+
// the input is unchanged we return the cached selection without
|
|
108
|
+
// re-running the selector — mirroring React's own
|
|
109
|
+
// useSyncExternalStoreWithSelector, which gates on snapshot
|
|
110
|
+
// identity (here the store's reads are not stable references, so
|
|
111
|
+
// we shallow-compare the input instead).
|
|
112
|
+
if (inputsEqual(memoizedInput, input)) {
|
|
113
|
+
return memoized;
|
|
114
|
+
}
|
|
115
|
+
memoizedInput = input;
|
|
116
|
+
const next = selector(...input);
|
|
117
|
+
if (equalityFn(memoized, next)) {
|
|
118
|
+
return memoized;
|
|
119
|
+
}
|
|
120
|
+
memoized = next;
|
|
121
|
+
return next;
|
|
122
|
+
};
|
|
123
|
+
}, [store, selector, equalityFn, deps, inst]);
|
|
124
|
+
const subscribe = useCallback((onStoreChange) => {
|
|
125
|
+
const listener = (names) => {
|
|
126
|
+
const currentDeps = depsRef.current;
|
|
127
|
+
if (currentDeps
|
|
128
|
+
&& !names.some((n) => currentDeps.indexOf(n) !== -1)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
onStoreChange();
|
|
132
|
+
};
|
|
133
|
+
store.control(ChangeEventName, listener);
|
|
134
|
+
return () => {
|
|
135
|
+
store.removeControl(ChangeEventName, listener);
|
|
136
|
+
};
|
|
137
|
+
}, [store]);
|
|
138
|
+
const value = useSyncExternalStore(subscribe, getSelection, getSelection);
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
inst.hasValue = true;
|
|
141
|
+
inst.value = value;
|
|
142
|
+
}, [value, inst]);
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
@@ -9,10 +9,22 @@ export function useStoreState(store, key) {
|
|
|
9
9
|
store.removeOnChange(key, listener);
|
|
10
10
|
};
|
|
11
11
|
}, [store, key]);
|
|
12
|
-
const getSnapshot = useCallback(
|
|
12
|
+
const getSnapshot = useCallback(
|
|
13
|
+
// getSnapshot can run for a still-mounted component after the store is
|
|
14
|
+
// destroyed (e.g. a provider torn down first), and must not throw out of
|
|
15
|
+
// render. On a destroyed store read via getData() (returns {} without
|
|
16
|
+
// asserting) instead of get() (which throws). Mirrors useStoreSelector.
|
|
17
|
+
() => (store.isDestroyed()
|
|
18
|
+
? store.getData()[key]
|
|
19
|
+
: store.get(key)), [store, key]);
|
|
13
20
|
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
14
21
|
const setter = useCallback((value) => {
|
|
15
22
|
if (typeof value === "function") {
|
|
23
|
+
// The cast is required by tsc (the typeof-narrowed `value` is
|
|
24
|
+
// `Setter | (ValueType & Function)`, not all callable), even
|
|
25
|
+
// though no-unnecessary-type-assertion disagrees on this TS
|
|
26
|
+
// version.
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
16
28
|
store.set(key, value(store.get(key)));
|
|
17
29
|
}
|
|
18
30
|
else {
|
package/dist/react.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./react/ErrorBoundary.js";
|
|
2
2
|
export * from "./react/useAction.js";
|
|
3
3
|
export * from "./react/useActionBus.js";
|
|
4
|
+
export * from "./react/useActionBusStatus.js";
|
|
5
|
+
export * from "./react/useAsyncAction.js";
|
|
4
6
|
export * from "./react/useActionMap.js";
|
|
5
7
|
export * from "./react/useEvent.js";
|
|
6
8
|
export * from "./react/useEventBus.js";
|
|
@@ -10,4 +12,5 @@ export * from "./react/useListenToEvent.js";
|
|
|
10
12
|
export * from "./react/useListenToEventBus.js";
|
|
11
13
|
export * from "./react/useListenToStoreChanges.js";
|
|
12
14
|
export * from "./react/useStore.js";
|
|
15
|
+
export * from "./react/useStoreSelector.js";
|
|
13
16
|
export * from "./react/useStoreState.js";
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./react/ErrorBoundary.js";
|
|
2
2
|
export * from "./react/useAction.js";
|
|
3
3
|
export * from "./react/useActionBus.js";
|
|
4
|
+
export * from "./react/useActionBusStatus.js";
|
|
5
|
+
export * from "./react/useAsyncAction.js";
|
|
4
6
|
export * from "./react/useActionMap.js";
|
|
5
7
|
export * from "./react/useEvent.js";
|
|
6
8
|
export * from "./react/useEventBus.js";
|
|
@@ -10,4 +12,5 @@ export * from "./react/useListenToEvent.js";
|
|
|
10
12
|
export * from "./react/useListenToEventBus.js";
|
|
11
13
|
export * from "./react/useListenToStoreChanges.js";
|
|
12
14
|
export * from "./react/useStore.js";
|
|
15
|
+
export * from "./react/useStoreSelector.js";
|
|
13
16
|
export * from "./react/useStoreState.js";
|
package/dist/store.d.ts
CHANGED
|
@@ -42,8 +42,11 @@ export declare function createStore<PropMap extends BasePropMap = BasePropMap>(i
|
|
|
42
42
|
<K extends KeyOf<PropMap>>(key: K, value: PropMap[K] | undefined): void;
|
|
43
43
|
(key: Partial<PropMap>): void;
|
|
44
44
|
};
|
|
45
|
+
readonly computed: <K extends KeyOf<PropMap>, const D extends readonly KeyOf<PropMap>[]>(key: K, deps: D, fn: (...values: { [I in keyof D]: PropMap[D[I]] | undefined; }) => PropMap[K]) => void;
|
|
45
46
|
readonly isEmpty: () => boolean;
|
|
46
47
|
readonly reset: () => void;
|
|
48
|
+
readonly destroy: () => void;
|
|
49
|
+
readonly isDestroyed: () => boolean;
|
|
47
50
|
readonly onChange: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, options?: import("./event.js").ListenerOptions) => void;
|
|
48
51
|
readonly removeOnChange: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, tag?: string | null) => void;
|
|
49
52
|
readonly updateOnChangeOptions: <K extends KeyOf<import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>>, H extends import("./eventBus.js").GetEventsMap<StoreChangeEvents<PropMap>>[K]["signature"]>(name: K, handler: H, context?: object | null, nextOptions?: import("./event.js").ListenerOptions) => boolean;
|