@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 CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @synapse-sdk/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of the Synapse SDK for JavaScript. Documentation: the repository.
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }