@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.
@@ -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(() => store.get(key), [store, key]);
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;