@synapse-sdk/react 0.0.0-stage → 0.3.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 +2 -2
- package/dist/index.d.ts +104 -0
- package/dist/index.js +373 -0
- package/package.json +29 -4
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synapse-sdk/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of the Synapse SDK for JavaScript. Documentation: the repository.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { Synapse, Catalog, ConnectionState, Notice, ViewOptions, ViewState, Run, CallOptions, Publisher, ServeHandler, ServeOptions, Registration, StoreHandle, StoreOptions, SceneEntity, SceneEntityLive, SceneHandle, SceneStatus, SceneTree } from "@synapse-sdk/client";
|
|
3
|
+
import type { ErrorBody, ProjectSnapshot, ServiceRef, TopicRef, SynapseError } from "@synapse-sdk/core";
|
|
4
|
+
export declare function SynapseProvider({ node, children }: {
|
|
5
|
+
node: Synapse;
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
}): import("react").FunctionComponentElement<import("react").ProviderProps<Synapse | null>>;
|
|
8
|
+
export declare function useSynapse(): Synapse;
|
|
9
|
+
/** The node from the nearest `<SynapseProvider>`, or `null` when there is none. */
|
|
10
|
+
export declare function useSynapseOptional(): Synapse | null;
|
|
11
|
+
/** The node's `ConnectionState`; a `setTarget` switch shows as `connecting` → `connected` with the new `url`. */
|
|
12
|
+
export declare function useConnection(): ConnectionState;
|
|
13
|
+
/** The runtime's project state (`node.project()`, WP §3.5); `undefined` between sessions or before the first snapshot. */
|
|
14
|
+
export declare function useProject(): ProjectSnapshot | undefined;
|
|
15
|
+
/** The runtime's catalogs as the current session last read them. */
|
|
16
|
+
export declare function useCatalog(): Catalog;
|
|
17
|
+
/** The last `limit` broker notices (`node.onNotice`) since the component mounted, newest last. */
|
|
18
|
+
export declare function useNotices(limit?: number): ErrorBody[];
|
|
19
|
+
/** The last `limit` `promoted`/`demoted` notices since the component mounted, newest last. */
|
|
20
|
+
export declare function useProjectNotices(limit?: number): Notice[];
|
|
21
|
+
/** Typed disabled sentinel: `useTopic("skip")` renders `pending` without subscribing. */
|
|
22
|
+
export type Skip = "skip";
|
|
23
|
+
/**
|
|
24
|
+
* A topic's value now (SDK §8). The view is made when the component mounts and closed when it
|
|
25
|
+
* unmounts (a view is subscribed for its whole life, like the Rust one; StrictMode's mount → unmount →
|
|
26
|
+
* mount makes a second view and closes the first). It is made again only when the topic or an option
|
|
27
|
+
* other than `select` changes. `select` runs here, over the view's value, with the selector of the
|
|
28
|
+
* latest render (so values it captures are current); a selected value deep-equal to the previous one
|
|
29
|
+
* keeps its reference.
|
|
30
|
+
*/
|
|
31
|
+
export declare function useTopic<TOut, S = TOut>(topic: TopicRef<unknown, TOut> | Skip, opts?: ViewOptions<TOut, S>): ViewState<S>;
|
|
32
|
+
/** TanStack's mutation vocabulary (`useMutation().status`); the protocol's `RunStatus` lives on `run.status`. */
|
|
33
|
+
export type CallStatus = "idle" | "pending" | "success" | "error";
|
|
34
|
+
/** A request/reply call as a mutation: `call(args)` returns the result; `status`/`data`/`error` follow the latest call. */
|
|
35
|
+
export declare function useCall<A, R>(svc: ServiceRef<A, unknown, unknown, R>, opts?: CallOptions<unknown>): {
|
|
36
|
+
call: (args: A) => Promise<R>;
|
|
37
|
+
reset(): void;
|
|
38
|
+
status: CallStatus;
|
|
39
|
+
data?: R;
|
|
40
|
+
error?: SynapseError;
|
|
41
|
+
};
|
|
42
|
+
export interface UseStartOptions<U> extends CallOptions<U> {
|
|
43
|
+
/** How many progress updates `updates` keeps (newest last); default 100. `latestUpdate` is always the newest. */
|
|
44
|
+
maxUpdates?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* `start()` a streaming call from a component. `status` is TanStack's (`pending` covers the spec's
|
|
48
|
+
* `pending` and `running`; `error` covers `failed` and `canceled` — `run.status` and `error.code`
|
|
49
|
+
* tell which). The run in flight is canceled with reason `"unmount"` when the component unmounts.
|
|
50
|
+
*/
|
|
51
|
+
export declare function useStart<A, U, R>(svc: ServiceRef<A, unknown, U, R>, opts?: UseStartOptions<U>): {
|
|
52
|
+
start: (args: A) => Run<U, R>;
|
|
53
|
+
cancel: (reason?: string) => Promise<void>;
|
|
54
|
+
run: Run<U, R> | undefined;
|
|
55
|
+
status: CallStatus;
|
|
56
|
+
updates: U[];
|
|
57
|
+
latestUpdate: U;
|
|
58
|
+
result: R | undefined;
|
|
59
|
+
error: SynapseError | undefined;
|
|
60
|
+
};
|
|
61
|
+
/** Registers a provider on mount and unregisters on unmount (see `acquireLease`). The handler may change between renders. */
|
|
62
|
+
export declare function useServe<I, U, R>(svc: ServiceRef<I, I, U, R>, handler: ServeHandler<I, U, R>, opts?: ServeOptions): {
|
|
63
|
+
ready: boolean;
|
|
64
|
+
error?: SynapseError;
|
|
65
|
+
registration?: Registration;
|
|
66
|
+
};
|
|
67
|
+
/** A publisher held for the component's life: `publish` drops (and counts) until it is admitted. */
|
|
68
|
+
export declare function usePublisher<TIn>(topic: TopicRef<TIn, unknown>): {
|
|
69
|
+
publish: (v: TIn) => void;
|
|
70
|
+
ready: boolean;
|
|
71
|
+
error?: SynapseError;
|
|
72
|
+
publisher?: Publisher<TIn>;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* A namespace as a value: `const [board, handle] = useStore("mission/board", { access: "public" })` — `board` re-renders on
|
|
76
|
+
* every change (the same object while nothing changed); `handle` for `set`/`delete`/`status`. `handle` is `null` until the
|
|
77
|
+
* open resolved (the value is `{}` then); an open refused for good (`project_inactive`) is in `error`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function useStore(name: string, opts?: StoreOptions): readonly [Record<string, unknown>, StoreHandle | null, SynapseError | undefined];
|
|
80
|
+
/**
|
|
81
|
+
* The active project's scene for the component's life: `[scene, error]`. `scene` is `null` until
|
|
82
|
+
* `node.scene()` resolved; it is closed when the component unmounts.
|
|
83
|
+
*/
|
|
84
|
+
export declare function useScene(): readonly [SceneHandle | null, SynapseError | undefined];
|
|
85
|
+
/**
|
|
86
|
+
* One entity of the scene (`scene.entity(id, { plane })`): the merged entity (or, with `plane`, that
|
|
87
|
+
* layer's value alone: `authored` or `live`), or what `select` picks from it (`select` gets
|
|
88
|
+
* `undefined` while there is none). The view reads the handle's copy
|
|
89
|
+
* of the scene and follows it only while the component is subscribed. `select` runs here with the
|
|
90
|
+
* selector of the latest render; a selected value deep-equal to the previous one keeps its reference,
|
|
91
|
+
* so a live pose that does not change what `select` picks does not re-render the component.
|
|
92
|
+
*/
|
|
93
|
+
export declare function useSceneEntity<S = SceneEntity | undefined>(scene: SceneHandle | null | undefined, id: string | null | undefined, opts?: {
|
|
94
|
+
plane?: "authored";
|
|
95
|
+
select?: (e: SceneEntity | undefined) => S;
|
|
96
|
+
}): S;
|
|
97
|
+
export declare function useSceneEntity<S = SceneEntityLive | undefined>(scene: SceneHandle | null | undefined, id: string | null | undefined, opts: {
|
|
98
|
+
plane: "live";
|
|
99
|
+
select?: (e: SceneEntityLive | undefined) => S;
|
|
100
|
+
}): S;
|
|
101
|
+
/** The scene's hierarchy (`scene.tree()`), as its roots: the same array while the structure does not change; empty without a scene. */
|
|
102
|
+
export declare function useSceneTree(scene: SceneHandle | null | undefined): SceneTree;
|
|
103
|
+
/** The scene handle's status (`scene.status`), re-rendering when a field changes; `undefined` without a scene. */
|
|
104
|
+
export declare function useSceneStatus(scene: SceneHandle | null | undefined): SceneStatus | undefined;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// @synapse-sdk/react: thin hooks over the client's cells (SDK §8). No wrapper state: every hook reads
|
|
2
|
+
// through useSyncExternalStore from a client object (a view, a store handle, the node's link).
|
|
3
|
+
//
|
|
4
|
+
// Statuses speak TanStack here (`idle | pending | success | error`, like `useMutation`); the spec's
|
|
5
|
+
// five-state `RunStatus` stays on the `Run` the hooks hand back. One rule, two layers.
|
|
6
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
7
|
+
import { replaceEqualDeep, shallowEqual } from "@synapse-sdk/client";
|
|
8
|
+
import { toSynapseError } from "@synapse-sdk/core";
|
|
9
|
+
const SynapseContext = createContext(null);
|
|
10
|
+
export function SynapseProvider({ node, children }) {
|
|
11
|
+
return createElement(SynapseContext.Provider, { value: node }, children);
|
|
12
|
+
}
|
|
13
|
+
export function useSynapse() {
|
|
14
|
+
const node = useContext(SynapseContext);
|
|
15
|
+
if (!node)
|
|
16
|
+
throw new Error("useSynapse: no <SynapseProvider> above this component");
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
/** The node from the nearest `<SynapseProvider>`, or `null` when there is none. */
|
|
20
|
+
export function useSynapseOptional() { return useContext(SynapseContext); }
|
|
21
|
+
/** The node's `ConnectionState`; a `setTarget` switch shows as `connecting` → `connected` with the new `url`. */
|
|
22
|
+
export function useConnection() {
|
|
23
|
+
const node = useSynapse();
|
|
24
|
+
// `node.status` builds a new object on every read; React needs the same object while nothing
|
|
25
|
+
// changed, so the last one read is kept and handed back while every field is the same.
|
|
26
|
+
const last = useRef(undefined);
|
|
27
|
+
const get = useCallback(() => {
|
|
28
|
+
const s = node.status;
|
|
29
|
+
if (!shallowEqual(last.current, s))
|
|
30
|
+
last.current = s;
|
|
31
|
+
return last.current;
|
|
32
|
+
}, [node]);
|
|
33
|
+
return useSyncExternalStore(useCallback((cb) => node.watchStatus(() => cb()), [node]), get, get);
|
|
34
|
+
}
|
|
35
|
+
/** The runtime's project state (`node.project()`, WP §3.5); `undefined` between sessions or before the first snapshot. */
|
|
36
|
+
export function useProject() {
|
|
37
|
+
const node = useSynapse();
|
|
38
|
+
return useSyncExternalStore(useCallback((cb) => node.watchProject(() => cb()), [node]), () => node.project(), () => node.project());
|
|
39
|
+
}
|
|
40
|
+
/** The runtime's catalogs as the current session last read them. */
|
|
41
|
+
export function useCatalog() {
|
|
42
|
+
const node = useSynapse();
|
|
43
|
+
const ref = useRef(node.catalog);
|
|
44
|
+
return useSyncExternalStore(useCallback((cb) => node.watchCatalog(c => { ref.current = c; cb(); }), [node]), () => ref.current, () => ref.current);
|
|
45
|
+
}
|
|
46
|
+
/** The last `limit` broker notices (`node.onNotice`) since the component mounted, newest last. */
|
|
47
|
+
export function useNotices(limit = 20) {
|
|
48
|
+
const node = useSynapse();
|
|
49
|
+
const [list, setList] = useState([]);
|
|
50
|
+
useEffect(() => node.onNotice(n => setList(l => [...l, n].slice(-limit))), [node, limit]);
|
|
51
|
+
return list;
|
|
52
|
+
}
|
|
53
|
+
/** The last `limit` `promoted`/`demoted` notices since the component mounted, newest last. */
|
|
54
|
+
export function useProjectNotices(limit = 20) {
|
|
55
|
+
const node = useSynapse();
|
|
56
|
+
const [list, setList] = useState([]);
|
|
57
|
+
useEffect(() => node.onProjectNotice(n => setList(l => [...l, n].slice(-limit))), [node, limit]);
|
|
58
|
+
return list;
|
|
59
|
+
}
|
|
60
|
+
const SKIP_STATE = { data: undefined, status: "pending", connection: "connecting", lastChangeAtUs: undefined };
|
|
61
|
+
/**
|
|
62
|
+
* A topic's value now (SDK §8). The view is made when the component mounts and closed when it
|
|
63
|
+
* unmounts (a view is subscribed for its whole life, like the Rust one; StrictMode's mount → unmount →
|
|
64
|
+
* mount makes a second view and closes the first). It is made again only when the topic or an option
|
|
65
|
+
* other than `select` changes. `select` runs here, over the view's value, with the selector of the
|
|
66
|
+
* latest render (so values it captures are current); a selected value deep-equal to the previous one
|
|
67
|
+
* keeps its reference.
|
|
68
|
+
*/
|
|
69
|
+
export function useTopic(topic, opts = {}) {
|
|
70
|
+
const node = useSynapse();
|
|
71
|
+
const { select, ...viewOpts } = opts;
|
|
72
|
+
const selectRef = useRef(select);
|
|
73
|
+
selectRef.current = select;
|
|
74
|
+
const key = JSON.stringify([typeof topic === "string" ? topic : topic.name, viewOpts]);
|
|
75
|
+
const topicRef = useRef(topic);
|
|
76
|
+
topicRef.current = topic;
|
|
77
|
+
const optsRef = useRef(viewOpts);
|
|
78
|
+
optsRef.current = viewOpts;
|
|
79
|
+
const [view, setView] = useState(undefined);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const t = topicRef.current;
|
|
82
|
+
if (t === "skip") {
|
|
83
|
+
setView(undefined);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const v = node.view(t, optsRef.current);
|
|
87
|
+
setView(v);
|
|
88
|
+
return () => { v.close(); };
|
|
89
|
+
}, [node, key]);
|
|
90
|
+
const subscribe = useCallback((cb) => (view ? view.subscribe(cb) : () => { }), [view]);
|
|
91
|
+
// The last result, with what it was made from: the same view state and selector give it back unchanged.
|
|
92
|
+
const last = useRef(undefined);
|
|
93
|
+
const get = useCallback(() => {
|
|
94
|
+
if (!view)
|
|
95
|
+
return SKIP_STATE;
|
|
96
|
+
const from = view.get();
|
|
97
|
+
const sel = selectRef.current;
|
|
98
|
+
if (!sel)
|
|
99
|
+
return from;
|
|
100
|
+
const prev = last.current;
|
|
101
|
+
if (prev && prev.from === from && prev.select === sel)
|
|
102
|
+
return prev.out;
|
|
103
|
+
const data = from.data === undefined ? undefined : replaceEqualDeep(prev?.out.data, sel(from.data));
|
|
104
|
+
const out = prev && shallowEqual(prev.out, { ...from, data }) ? prev.out : { ...from, data };
|
|
105
|
+
last.current = { from, select: sel, out };
|
|
106
|
+
return out;
|
|
107
|
+
}, [view]);
|
|
108
|
+
return useSyncExternalStore(subscribe, get, get);
|
|
109
|
+
}
|
|
110
|
+
/** A service as a name: what the hooks key their memoisation on, so an inline contract (a new object every render) is fine. */
|
|
111
|
+
function nameOf(svc) { return typeof svc === "string" ? svc : svc.name; }
|
|
112
|
+
/** A request/reply call as a mutation: `call(args)` returns the result; `status`/`data`/`error` follow the latest call. */
|
|
113
|
+
export function useCall(svc, opts = {}) {
|
|
114
|
+
const node = useSynapse();
|
|
115
|
+
const [state, setState] = useState({ status: "idle" });
|
|
116
|
+
const optsRef = useRef(opts);
|
|
117
|
+
optsRef.current = opts;
|
|
118
|
+
const svcRef = useRef(svc);
|
|
119
|
+
svcRef.current = svc;
|
|
120
|
+
const name = nameOf(svc);
|
|
121
|
+
const seq = useRef(0);
|
|
122
|
+
const call = useCallback(async (args) => {
|
|
123
|
+
const my = ++seq.current;
|
|
124
|
+
setState(s => ({ ...s, status: "pending", error: undefined }));
|
|
125
|
+
try {
|
|
126
|
+
const r = await node.call(svcRef.current, args, optsRef.current).result;
|
|
127
|
+
if (my === seq.current)
|
|
128
|
+
setState({ status: "success", data: r });
|
|
129
|
+
return r;
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
const err = toSynapseError(e);
|
|
133
|
+
if (my === seq.current)
|
|
134
|
+
setState(s => ({ ...s, status: "error", error: err }));
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
|
+
}, [node, name]);
|
|
139
|
+
const reset = useCallback(() => { seq.current++; setState({ status: "idle" }); }, []);
|
|
140
|
+
return { call, reset, ...state };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* `start()` a streaming call from a component. `status` is TanStack's (`pending` covers the spec's
|
|
144
|
+
* `pending` and `running`; `error` covers `failed` and `canceled` — `run.status` and `error.code`
|
|
145
|
+
* tell which). The run in flight is canceled with reason `"unmount"` when the component unmounts.
|
|
146
|
+
*/
|
|
147
|
+
export function useStart(svc, opts = {}) {
|
|
148
|
+
const node = useSynapse();
|
|
149
|
+
const [run, setRun] = useState(undefined);
|
|
150
|
+
const [status, setStatus] = useState("idle");
|
|
151
|
+
const [updates, setUpdates] = useState([]);
|
|
152
|
+
const [result, setResult] = useState(undefined);
|
|
153
|
+
const [error, setError] = useState(undefined);
|
|
154
|
+
const optsRef = useRef(opts);
|
|
155
|
+
optsRef.current = opts;
|
|
156
|
+
const svcRef = useRef(svc);
|
|
157
|
+
svcRef.current = svc;
|
|
158
|
+
const runRef = useRef(undefined);
|
|
159
|
+
const name = nameOf(svc);
|
|
160
|
+
const start = useCallback((args) => {
|
|
161
|
+
const { maxUpdates = 100, ...callOpts } = optsRef.current;
|
|
162
|
+
const keep = Math.max(1, maxUpdates);
|
|
163
|
+
const r = node.call(svcRef.current, args, { ...callOpts, onUpdate: u => { if (runRef.current === r)
|
|
164
|
+
setUpdates(prev => (prev.length < keep ? [...prev, u] : [...prev.slice(prev.length - keep + 1), u])); optsRef.current.onUpdate?.(u); } });
|
|
165
|
+
runRef.current = r;
|
|
166
|
+
setRun(r);
|
|
167
|
+
setStatus("pending");
|
|
168
|
+
setUpdates([]);
|
|
169
|
+
setResult(undefined);
|
|
170
|
+
setError(undefined);
|
|
171
|
+
r.result.then(v => { if (runRef.current === r) {
|
|
172
|
+
setResult(v);
|
|
173
|
+
setStatus("success");
|
|
174
|
+
} }, e => { if (runRef.current === r) {
|
|
175
|
+
setError(toSynapseError(e));
|
|
176
|
+
setStatus("error");
|
|
177
|
+
} });
|
|
178
|
+
return r;
|
|
179
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
180
|
+
}, [node, name]);
|
|
181
|
+
const cancel = useCallback((reason) => runRef.current?.cancel(reason) ?? Promise.resolve(), []);
|
|
182
|
+
useEffect(() => () => { const r = runRef.current; runRef.current = undefined; void r?.cancel("unmount"); }, []);
|
|
183
|
+
return { start, cancel, run, status, updates, latestUpdate: updates[updates.length - 1], result, error };
|
|
184
|
+
}
|
|
185
|
+
const leases = new WeakMap();
|
|
186
|
+
function acquireLease(node, svc, handler, opts) {
|
|
187
|
+
let byName = leases.get(node);
|
|
188
|
+
if (!byName) {
|
|
189
|
+
byName = new Map();
|
|
190
|
+
leases.set(node, byName);
|
|
191
|
+
}
|
|
192
|
+
const name = nameOf(svc);
|
|
193
|
+
let lease = byName.get(name);
|
|
194
|
+
if (lease) {
|
|
195
|
+
if (lease.release !== undefined) {
|
|
196
|
+
clearTimeout(lease.release);
|
|
197
|
+
lease.release = undefined;
|
|
198
|
+
}
|
|
199
|
+
lease.users++;
|
|
200
|
+
lease.handler = handler;
|
|
201
|
+
return lease;
|
|
202
|
+
}
|
|
203
|
+
const l = { reg: undefined, handler, users: 1 };
|
|
204
|
+
l.reg = node.serve(svc, (input, ctx) => l.handler(input, ctx), opts);
|
|
205
|
+
l.reg.catch(() => { });
|
|
206
|
+
byName.set(name, l);
|
|
207
|
+
return l;
|
|
208
|
+
}
|
|
209
|
+
function releaseLease(node, name, lease) {
|
|
210
|
+
lease.users--;
|
|
211
|
+
if (lease.users > 0)
|
|
212
|
+
return;
|
|
213
|
+
lease.release = setTimeout(() => {
|
|
214
|
+
lease.release = undefined;
|
|
215
|
+
if (lease.users > 0)
|
|
216
|
+
return;
|
|
217
|
+
const byName = leases.get(node);
|
|
218
|
+
if (byName?.get(name) === lease)
|
|
219
|
+
byName.delete(name);
|
|
220
|
+
void lease.reg.then(r => r.unregister(), () => { });
|
|
221
|
+
}, 0);
|
|
222
|
+
}
|
|
223
|
+
/** Registers a provider on mount and unregisters on unmount (see `acquireLease`). The handler may change between renders. */
|
|
224
|
+
export function useServe(svc, handler, opts = {}) {
|
|
225
|
+
const node = useSynapse();
|
|
226
|
+
const handlerRef = useRef(handler);
|
|
227
|
+
handlerRef.current = handler;
|
|
228
|
+
const svcRef = useRef(svc);
|
|
229
|
+
svcRef.current = svc;
|
|
230
|
+
const optsRef = useRef(opts);
|
|
231
|
+
optsRef.current = opts;
|
|
232
|
+
const [state, setState] = useState({ ready: false });
|
|
233
|
+
const name = nameOf(svc);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const lease = acquireLease(node, svcRef.current, (input, ctx) => handlerRef.current(input, ctx), optsRef.current);
|
|
236
|
+
let mine = true;
|
|
237
|
+
lease.reg.then(reg => { if (mine)
|
|
238
|
+
setState({ ready: true, registration: reg }); }, e => { if (mine)
|
|
239
|
+
setState({ ready: false, error: toSynapseError(e) }); });
|
|
240
|
+
return () => { mine = false; releaseLease(node, name, lease); };
|
|
241
|
+
}, [node, name]);
|
|
242
|
+
return state;
|
|
243
|
+
}
|
|
244
|
+
/** A publisher held for the component's life: `publish` drops (and counts) until it is admitted. */
|
|
245
|
+
export function usePublisher(topic) {
|
|
246
|
+
const node = useSynapse();
|
|
247
|
+
const name = typeof topic === "string" ? topic : topic.name;
|
|
248
|
+
const topicRef = useRef(topic);
|
|
249
|
+
topicRef.current = topic;
|
|
250
|
+
const [state, setState] = useState({ ready: false });
|
|
251
|
+
const pubRef = useRef(undefined);
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
let mine = true;
|
|
254
|
+
let pub;
|
|
255
|
+
node.publisher(topicRef.current).then(p => { if (!mine) {
|
|
256
|
+
p.close();
|
|
257
|
+
return;
|
|
258
|
+
} pub = p; pubRef.current = p; setState({ ready: true, publisher: p }); }, e => { if (mine)
|
|
259
|
+
setState({ ready: false, error: toSynapseError(e) }); });
|
|
260
|
+
return () => { mine = false; pub?.close(); if (pubRef.current === pub)
|
|
261
|
+
pubRef.current = undefined; };
|
|
262
|
+
}, [node, name]);
|
|
263
|
+
const publish = useCallback((v) => { pubRef.current?.publish(v); }, []);
|
|
264
|
+
return { publish, ...state };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A namespace as a value: `const [board, handle] = useStore("mission/board", { access: "public" })` — `board` re-renders on
|
|
268
|
+
* every change (the same object while nothing changed); `handle` for `set`/`delete`/`status`. `handle` is `null` until the
|
|
269
|
+
* open resolved (the value is `{}` then); an open refused for good (`project_inactive`) is in `error`.
|
|
270
|
+
*/
|
|
271
|
+
export function useStore(name, opts = {}) {
|
|
272
|
+
const node = useSynapse();
|
|
273
|
+
const key = JSON.stringify([name, opts.access, opts.onDeath]);
|
|
274
|
+
const [handle, setHandle] = useState(null);
|
|
275
|
+
const [error, setError] = useState(undefined);
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
let mine = true;
|
|
278
|
+
let h;
|
|
279
|
+
setHandle(null);
|
|
280
|
+
setError(undefined);
|
|
281
|
+
node.store(name, opts).then(s => { if (!mine) {
|
|
282
|
+
s.close();
|
|
283
|
+
return;
|
|
284
|
+
} h = s; setHandle(s); }, e => { if (mine)
|
|
285
|
+
setError(toSynapseError(e)); });
|
|
286
|
+
return () => { mine = false; h?.close(); };
|
|
287
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
288
|
+
}, [node, key]);
|
|
289
|
+
const subscribe = useCallback((cb) => (handle ? handle.subscribe(cb) : () => { }), [handle]);
|
|
290
|
+
// React re-renders when the snapshot changes: it holds the value and the handle's status, so a
|
|
291
|
+
// change of `synced`/`pending`/`connected` alone re-renders too (the component reads them on
|
|
292
|
+
// `handle`). `handle.status` builds a new object on every read, so the snapshot is rebuilt only
|
|
293
|
+
// when the value or a status field differs.
|
|
294
|
+
const last = useRef(undefined);
|
|
295
|
+
const get = useCallback(() => {
|
|
296
|
+
if (!handle)
|
|
297
|
+
return undefined;
|
|
298
|
+
const value = handle.value, status = handle.status;
|
|
299
|
+
const prev = last.current;
|
|
300
|
+
if (!prev || prev.handle !== handle || prev.value !== value || !shallowEqual(prev.status, status))
|
|
301
|
+
last.current = { handle, value, status };
|
|
302
|
+
return last.current;
|
|
303
|
+
}, [handle]);
|
|
304
|
+
const snap = useSyncExternalStore(subscribe, get, get);
|
|
305
|
+
return [snap ? snap.value : EMPTY, handle, error];
|
|
306
|
+
}
|
|
307
|
+
const EMPTY = {};
|
|
308
|
+
/**
|
|
309
|
+
* The active project's scene for the component's life: `[scene, error]`. `scene` is `null` until
|
|
310
|
+
* `node.scene()` resolved; it is closed when the component unmounts.
|
|
311
|
+
*/
|
|
312
|
+
export function useScene() {
|
|
313
|
+
const node = useSynapse();
|
|
314
|
+
const [scene, setScene] = useState(null);
|
|
315
|
+
const [error, setError] = useState(undefined);
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
let mine = true;
|
|
318
|
+
let h;
|
|
319
|
+
setScene(null);
|
|
320
|
+
setError(undefined);
|
|
321
|
+
node.scene().then(s => { if (!mine) {
|
|
322
|
+
s.close();
|
|
323
|
+
return;
|
|
324
|
+
} h = s; setScene(s); }, e => { if (mine)
|
|
325
|
+
setError(toSynapseError(e)); });
|
|
326
|
+
return () => { mine = false; h?.close(); };
|
|
327
|
+
}, [node]);
|
|
328
|
+
return [scene, error];
|
|
329
|
+
}
|
|
330
|
+
export function useSceneEntity(scene, id, opts = {}) {
|
|
331
|
+
const selectRef = useRef(opts.select);
|
|
332
|
+
selectRef.current = opts.select;
|
|
333
|
+
const plane = opts.plane;
|
|
334
|
+
const view = useMemo(() => (scene && id ? scene.entity(id, plane ? { plane } : undefined) : undefined), [scene, id, plane]);
|
|
335
|
+
const subscribe = useCallback((cb) => (view ? view.subscribe(cb) : () => { }), [view]);
|
|
336
|
+
const last = useRef(undefined);
|
|
337
|
+
const get = useCallback(() => {
|
|
338
|
+
const from = view?.get();
|
|
339
|
+
const sel = selectRef.current;
|
|
340
|
+
if (!sel)
|
|
341
|
+
return from;
|
|
342
|
+
const prev = last.current;
|
|
343
|
+
if (prev && prev.from === from && prev.select === sel)
|
|
344
|
+
return prev.out;
|
|
345
|
+
const out = replaceEqualDeep(prev?.out, sel(from));
|
|
346
|
+
last.current = { from, select: sel, out };
|
|
347
|
+
return out;
|
|
348
|
+
}, [view]);
|
|
349
|
+
return useSyncExternalStore(subscribe, get, get);
|
|
350
|
+
}
|
|
351
|
+
const EMPTY_TREE = [];
|
|
352
|
+
/** The scene's hierarchy (`scene.tree()`), as its roots: the same array while the structure does not change; empty without a scene. */
|
|
353
|
+
export function useSceneTree(scene) {
|
|
354
|
+
const view = useMemo(() => scene?.tree(), [scene]);
|
|
355
|
+
const subscribe = useCallback((cb) => (view ? view.subscribe(cb) : () => { }), [view]);
|
|
356
|
+
const get = useCallback(() => view?.get() ?? EMPTY_TREE, [view]);
|
|
357
|
+
return useSyncExternalStore(subscribe, get, get);
|
|
358
|
+
}
|
|
359
|
+
/** The scene handle's status (`scene.status`), re-rendering when a field changes; `undefined` without a scene. */
|
|
360
|
+
export function useSceneStatus(scene) {
|
|
361
|
+
// `scene.status` builds a new object on every read: the last one is kept while every field is the same.
|
|
362
|
+
const last = useRef(undefined);
|
|
363
|
+
const subscribe = useCallback((cb) => (scene ? scene.subscribe(cb) : () => { }), [scene]);
|
|
364
|
+
const get = useCallback(() => {
|
|
365
|
+
if (!scene)
|
|
366
|
+
return undefined;
|
|
367
|
+
const s = scene.status;
|
|
368
|
+
if (!shallowEqual(last.current, s))
|
|
369
|
+
last.current = s;
|
|
370
|
+
return last.current;
|
|
371
|
+
}, [scene]);
|
|
372
|
+
return useSyncExternalStore(subscribe, get, get);
|
|
373
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapse-sdk/react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Synapse SDK React hooks: connection, topics, calls, services, store.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@synapse-sdk/core": "0.3.0",
|
|
18
|
+
"@synapse-sdk/client": "0.3.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"license": "UNLICENSED",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/taiga-robotics/synapse"
|
|
30
|
+
}
|
|
31
|
+
}
|