@synapse-sdk/react 0.3.3 → 0.3.4

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,14 +1,104 @@
1
1
  # @synapse-sdk/react
2
2
 
3
3
  React hooks for a Synapse node: `<SynapseProvider node={node}>` above your components, then `useConnection()`,
4
- `useProject()`, `useCatalog()`, `useTopic(t, { select })`, `useCall(svc)`, `useStart(svc)`, `useServe(svc, handler)`,
5
- `usePublisher(t)`, `useStore(name)`, `useScene()`, `useSceneEntity(scene, id, { select, plane })`, `useSceneTree(scene)`,
4
+ `useProject()`, `useCatalog()` (the node's `status`, `project` and `catalog` cells), `useTopic(t, { select })`, `useCall(svc)`, `useServe(svc, handler)`,
5
+ `usePublisher(t)`, `useStore(name)`, `useScene()`, `useSceneEntity(scene, id | { name }, { select })`, `useSceneTree(scene)`,
6
6
  `useSceneStatus(scene)` and `useTasks(select?)`. Every hook reads a value the node keeps current; none keeps its own
7
7
  copy.
8
8
 
9
+ ## Two kinds of users
10
+
11
+ - **Node author** (a robot driver, a sim, a task, a panel that edits a few known things): `node.store(name)` with
12
+ `get()`/`set()`/`watch()`; `scene.authored.upsert(kind, { name, ... })`, keyed by name, resolves with the entity's view;
13
+ `scene.entity(id)` or `scene.entity({ name })`, a view of the merged entity with `get()`/`get(component)`/`watch()` and
14
+ its two planes: `view.authored.get()`/`.set(...)` (one patch of the authored entity) and `view.live.get()` (the live row,
15
+ its `stamp`)/`.set({ xform, joints, state }, { lifetimeMs })` (its live lanes; the first write of an id asks its
16
+ override claim and lands once granted)/`await .remove()` (the release call); the view's own `watch`/`subscribe`/`changed`
17
+ cover both planes; `await scene.live.upsert(kind, { name?, parent?, ... }, { lifetimeMs })` for a live entity of the
18
+ node's own (the claim call: the view of the id the runtime answered; claimed again, same id, on every attach until
19
+ `await scene.live.remove(x)`); `scene.live.batch({ poses, joints, state, lifetimeMs })` (lanes only) for a sim's one write per tick.
20
+ Decisions are calls, data is streams.
21
+ - **Engine or editor builder** (a 3D view, a scene editor): `scene.authored.spawn`/`patch`/`rename`/`reparent`/`despawn`/
22
+ `setLocked`, `txn`/`apply` (an entity named by its id or its view), and the feeds (`scene.watch()` for the authored
23
+ changes, `watchLiveRemovals`, `watchLiveDenied`).
24
+
25
+ Every handle (store, scene) has one shape: `ready()`, `status` (a cell: `get()`, `subscribe(listener)`, `changed()`),
26
+ `synced`, `watch()`, `changed()`, `close()`. `useSceneStatus(scene)` reads `scene.status`; `useNotices()` follows
27
+ `node.notices(cb)`.
28
+
29
+ ```tsx
30
+ const tool = useSceneEntity(scene, { name: "tool" }, { select: e => e?.props?.speed }); // follows the name
31
+ const view = useMemo(() => scene?.entity({ name: "tool" }), [scene]);
32
+ <button onClick={() => view?.authored.set("props", { speed: 2 })}>faster</button> // one patch of whoever holds the name now
33
+ const busy = useSceneEntity(scene, { name: "tool" }, { select: e => e?.state?.busy }); // merged: the live state lane shows
34
+ // the live row alone (its `stamp`) is the view's, read in a frame loop: view.live.get()?.stamp (no render)
35
+ ```
36
+
37
+ ## Connecting
38
+
39
+ `new Synapse(opts)` is the only way to make a node; `opts` is who the node is (`type`, `name`, ...), never where it
40
+ connects. It returns a node that works at once: its status, every hook and every handle, before any connection.
41
+ Constructing opens nothing (safe during SSR); only `connect(target)` dials (a script awaits it; an app does not). Before
42
+ the first session the node behaves as it does between two sessions: `useConnection()` is `idle` (no target yet) or
43
+ `connecting`, the catalog is empty, `useStore`/`useScene` hand back their handle once the first session brings it in,
44
+ `useTasks()` is empty with `synced: false`, and a call fails `not_connected`.
45
+
46
+ ```tsx
47
+ import { useEffect } from "react";
48
+ import { Synapse } from "@synapse-sdk/client";
49
+ import { SynapseProvider, useConnection } from "@synapse-sdk/react";
50
+
51
+ const node = new Synapse({ type: "hmi" }); // or useState(() => new Synapse({ type: "hmi" })) in a component
52
+
53
+ function Robot({ robot }: { robot?: string }) { // the robot's url, picked from the app's URL
54
+ useEffect(() => {
55
+ if (robot) node.connect({ url: robot }); // not awaited: a failure shows as `failed` in useConnection()
56
+ else node.disconnect(); // "no robot": `idle`, every handle kept
57
+ }, [robot]);
58
+ return null;
59
+ }
60
+
61
+ function Status() {
62
+ const c = useConnection();
63
+ switch (c.status) {
64
+ case "connected": return <span>connected to {c.url}</span>;
65
+ case "connecting": case "reconnecting": return <span>connecting…</span>;
66
+ case "failed": return <button onClick={() => node.connect()}>{c.error.code}: retry</button>; // resting: connect() dials the target again
67
+ case "idle": return <span>no robot selected</span>; // no target (disconnect())
68
+ default: return <span>{c.status}</span>; // closing / closed (after close())
69
+ }
70
+ }
71
+
72
+ export const App = ({ robot }: { robot?: string }) => <SynapseProvider node={node}><Robot robot={robot} /><Status />{/* … */}</SynapseProvider>;
73
+ ```
74
+
75
+ `connect({ url })` (or `connect(url)`) closes the old session, if any, before the new target is dialed; every handle
76
+ re-binds to the new session as across a reconnect. Operator intent is never lost under quick clicking: the newest call
77
+ wins, and a call superseded by a newer `connect()` or `disconnect()` resolves quietly. The same target again dials
78
+ nothing and settles with it. `connect()` with no target settles with the declared one. `failed` is not the end: the
79
+ node rests there until the next `connect()`; only `close()` ends it (`closed`).
80
+
81
+ ## Calls
82
+
83
+ `useCall(svc, opts)` is the one call hook, request/reply or streaming: `{ call, cancel, status, data, error, updates,
84
+ latestUpdate, run, reset }`. `status` speaks TanStack (`idle | pending | success | error`); the spec's run status is on
85
+ `run.status`. `call(args)` returns the `Run`: ignore it (the hook handles the outcome: a failure lands in `error`, never
86
+ an unhandled rejection) or `await` it (resolves with the result, rejects with the error). `cancel(reason?)` never
87
+ rejects: it resolves once the run has ended, and the outcome is what `status`/`error` show (a canceled run is `error`
88
+ with `error.code === "canceled"`). Every run still in flight is canceled with reason `"unmount"` when the component
89
+ unmounts. `updates` keeps the newest `maxUpdates` (default 100); `reset()` goes back to `idle`.
90
+
91
+ ```tsx
92
+ const { call, cancel, status, data, error, latestUpdate } = useCall(api.arm.moveTo);
93
+ <button onClick={() => call({ target: 1.2 })} disabled={status === "pending"}>move</button>
94
+ {status === "pending" && <><progress value={latestUpdate?.progress ?? 0} /><button onClick={() => cancel()}>stop</button></>}
95
+ {status === "success" && <span>reached: {String(data!.reached)}</span>}
96
+ {status === "error" && <span>{error!.code}</span>}
97
+ ```
98
+
9
99
  ## Tasks
10
100
 
11
- `useTasks()` is one view: `status` (what runs, `node.tasks.status.value`), `catalog` (what the runtime offers), `synced`,
101
+ `useTasks()` is one view: `status` (what runs, `node.tasks.status.get()`), `catalog` (what the runtime offers), `synced`,
12
102
  and the four verbs; the view is the same object while none of the three values changed. `useTasks(select)` is what
13
103
  `select` picks from that view, and the component re-renders only when the selection changes (an unchanged selection
14
104
  keeps its object). A button that only runs and stops needs no subscription: it calls the verbs on the node.
@@ -33,7 +123,7 @@ const sim = useTasks(t => t.status.project.tasks[entityId]?.sim?.state);
33
123
  const { catalog, synced } = useTasks();
34
124
  ```
35
125
 
36
- The node must be connected when the hook first renders (`node.tasks` throws `not_connected` before).
126
+ `useTasks()` works from the first render, connected or not: before a session `status` and `catalog` are empty and `synced` is false.
37
127
 
38
128
  ## 3D reads, HTML renders
39
129
 
@@ -44,10 +134,10 @@ for the HTML side: they wake a component only when its selection changes.
44
134
 
45
135
  | what | in the frame loop (no render) | hook for HTML (wakes on) |
46
136
  |---|---|---|
47
- | one entity | `const v = useMemo(() => scene.entity(id), [scene, id])`; `v.get()` | `useSceneEntity(scene, id, { select })` (the selection changing) |
137
+ | one entity | `const v = useMemo(() => scene.entity(id), [scene, id])` (or `scene.entity({ name })`); `v.get()`, `v.get("xform")` | `useSceneEntity(scene, id \| { name }, { select })` (the selection changing) |
48
138
  | a topic | `const v = useMemo(() => node.view(t), [t])` (close it on unmount); `v.get().data` | `useTopic(t, { select })` (the selection changing) |
49
- | a namespace | `store.value` | `useStore(name)` (any change of the namespace) |
50
- | tasks | `node.tasks.status.value` | `useTasks(select)` (the selection changing) |
139
+ | a namespace | `store.get()` (or `store.get(key)`) | `useStore(name)` (any change of the namespace) |
140
+ | tasks | `node.tasks.status.get()` | `useTasks(select)` (the selection changing) |
51
141
 
52
142
  ```tsx
53
143
  function Arm({ scene, id }: { scene: SceneHandle; id: string }) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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, TasksStatus, TaskCatalogEntry, TasksRun } from "@synapse-sdk/client";
2
+ import type { Synapse, Catalog, ConnectionState, ViewOptions, ViewState, Run, CallOptions, Publisher, ServeHandler, ServeOptions, Registration, StoreHandle, StoreOptions, SceneEntity, SceneEntityRef, SceneHandle, SceneStatus, SceneTree, TasksStatus, TaskCatalogEntry, TasksRun } from "@synapse-sdk/client";
3
3
  import type { ErrorBody, ProjectSnapshot, ServiceRef, TopicRef, SynapseError } from "@synapse-sdk/core";
4
4
  export declare function SynapseProvider({ node, children }: {
5
5
  node: Synapse;
@@ -8,16 +8,14 @@ export declare function SynapseProvider({ node, children }: {
8
8
  export declare function useSynapse(): Synapse;
9
9
  /** The node from the nearest `<SynapseProvider>`, or `null` when there is none. */
10
10
  export declare function useSynapseOptional(): Synapse | null;
11
- /** The node's `ConnectionState`; a `setTarget` switch shows as `connecting` → `connected` with the new `url`. */
11
+ /** The node's `ConnectionState` (`node.status`); a `connect(target)` switch shows as `connecting` → `connected` with the new `url`. */
12
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. */
13
+ /** The runtime's project state (`node.project`, WP §3.5); `undefined` between sessions or before the first snapshot. */
14
14
  export declare function useProject(): ProjectSnapshot | undefined;
15
- /** The runtime's catalogs as the current session last read them. */
15
+ /** The runtime's catalogs as the current session last read them (`node.catalog`). */
16
16
  export declare function useCatalog(): Catalog;
17
- /** The last `limit` broker notices (`node.onNotice`) since the component mounted, newest last. */
17
+ /** The last `limit` notices (`node.notices`) since the component mounted, newest last. */
18
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
19
  /** Typed disabled sentinel: `useTopic("skip")` renders `pending` without subscribing. */
22
20
  export type Skip = "skip";
23
21
  /**
@@ -31,32 +29,30 @@ export type Skip = "skip";
31
29
  export declare function useTopic<TOut, S = TOut>(topic: TopicRef<unknown, TOut> | Skip, opts?: ViewOptions<TOut, S>): ViewState<S>;
32
30
  /** TanStack's mutation vocabulary (`useMutation().status`); the protocol's `RunStatus` lives on `run.status`. */
33
31
  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> {
32
+ export interface UseCallOptions<U> extends CallOptions<U> {
43
33
  /** How many progress updates `updates` keeps (newest last); default 100. `latestUpdate` is always the newest. */
44
34
  maxUpdates?: number;
45
35
  }
46
36
  /**
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.
37
+ * A call from a component, request/reply or streaming: `call(args)` starts it and returns its `Run`; `status`, `data`,
38
+ * `error`, `updates`, `latestUpdate` and `run` follow the latest call. `status` is TanStack's (`pending` covers the
39
+ * spec's `pending` and `running`; `error` covers `failed` and `canceled`: `run.status` and `error.code` tell which).
40
+ * The hook handles every run's outcome itself, so `call(args)` may be ignored (no unhandled rejection) or awaited
41
+ * (`await call(args)` resolves with the result or rejects with the error). `cancel(reason?)` cancels the latest run and
42
+ * resolves once it has ended, never rejecting: its outcome (`canceled`, or the result that won the race) is what
43
+ * `status`/`error`/`data` show. Every run still in flight is canceled with reason `"unmount"` when the component
44
+ * unmounts. `reset()` forgets the latest call (back to `idle`); a run in flight goes on, unfollowed.
50
45
  */
51
- export declare function useStart<A, U, R>(svc: ServiceRef<A, unknown, U, R>, opts?: UseStartOptions<U>): {
52
- start: (args: A) => Run<U, R>;
46
+ export declare function useCall<A, U, R>(svc: ServiceRef<A, unknown, U, R>, opts?: UseCallOptions<U>): {
47
+ call: (args: A) => Run<U, R>;
53
48
  cancel: (reason?: string) => Promise<void>;
49
+ reset: () => void;
54
50
  run: Run<U, R> | undefined;
55
51
  status: CallStatus;
52
+ data: R | undefined;
53
+ error: SynapseError | undefined;
56
54
  updates: U[];
57
55
  latestUpdate: U;
58
- result: R | undefined;
59
- error: SynapseError | undefined;
60
56
  };
61
57
  /** Registers a provider on mount and unregisters on unmount (see `acquireLease`). The handler may change between renders. */
62
58
  export declare function useServe<I, U, R>(svc: ServiceRef<I, I, U, R>, handler: ServeHandler<I, U, R>, opts?: ServeOptions): {
@@ -83,24 +79,20 @@ export declare function useStore(name: string, opts?: StoreOptions): readonly [R
83
79
  */
84
80
  export declare function useScene(): readonly [SceneHandle | null, SynapseError | undefined];
85
81
  /**
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
82
+ * One entity of the scene: `scene.entity(id | { name })`, the merged entity (authored with its live overrides), or what
83
+ * `select` picks from it (`select` gets `undefined` while there is none). One plane alone (`view.authored.get()`,
84
+ * `view.live.get()` with its `stamp`) is read imperatively from the view, as a frame loop does. By name (`useSceneEntity(scene, { name: "tool" })`) it follows the name: the
85
+ * entity holding it now. The view reads the handle's copy
89
86
  * of the scene and follows it only while the component is subscribed. `select` runs here with the
90
87
  * selector of the latest render; a selected value deep-equal to the previous one keeps its reference,
91
88
  * so a live pose that does not change what `select` picks does not re-render the component.
92
89
  */
93
- export declare function useSceneEntity<S = SceneEntity | undefined>(scene: SceneHandle | null | undefined, id: string | null | undefined, opts?: {
94
- plane?: "authored";
90
+ export declare function useSceneEntity<S = SceneEntity | undefined>(scene: SceneHandle | null | undefined, ref: SceneEntityRef | null | undefined, opts?: {
95
91
  select?: (e: SceneEntity | undefined) => S;
96
92
  }): 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
93
  /** The scene's hierarchy (`scene.tree()`), as its roots: the same array while the structure does not change; empty without a scene. */
102
94
  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. */
95
+ /** The scene handle's status (`scene.status`, a state value), re-rendering when a field changes; `undefined` without a scene. */
104
96
  export declare function useSceneStatus(scene: SceneHandle | null | undefined): SceneStatus | undefined;
105
97
  /** What `useTasks` hands back: the facet's three values as the node keeps them current, and its verbs (stable for the node's life). */
106
98
  export interface TasksView {
@@ -120,7 +112,7 @@ export interface TasksView {
120
112
  * `select` runs here with the selector of the latest render; a selected value deep-equal to the previous one keeps its
121
113
  * reference, so a change elsewhere (another task's restart, a catalog refresh) does not re-render the component.
122
114
  * A component that only runs and stops needs no subscription: `useSynapse().tasks.runProjectTasks()`.
123
- * The provider's node must be connected (`node.tasks` throws `not_connected` before).
115
+ * Works from the first render, connected or not: before a session `status`/`catalog` are empty and `synced` is false.
124
116
  */
125
117
  export declare function useTasks(): TasksView;
126
118
  export declare function useTasks<S>(select: (t: TasksView) => S): S;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
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).
1
+ // @synapse-sdk/react: thin hooks over the client's state values (SDK §8). No wrapper state: every hook reads
2
+ // through useSyncExternalStore from a client object (a view, a store handle, the node's state values).
3
3
  //
4
4
  // Statuses speak TanStack here (`idle | pending | success | error`, like `useMutation`); the spec's
5
5
  // five-state `RunStatus` stays on the `Run` the hooks hand back. One rule, two layers.
@@ -18,43 +18,26 @@ export function useSynapse() {
18
18
  }
19
19
  /** The node from the nearest `<SynapseProvider>`, or `null` when there is none. */
20
20
  export function useSynapseOptional() { return useContext(SynapseContext); }
21
- /** The node's `ConnectionState`; a `setTarget` switch shows as `connecting` → `connected` with the new `url`. */
21
+ /** The node's `ConnectionState` (`node.status`); a `connect(target)` switch shows as `connecting` → `connected` with the new `url`. */
22
22
  export function useConnection() {
23
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);
24
+ return useSyncExternalStore(useCallback((cb) => node.status.onChange(() => cb()), [node]), () => node.status.get(), () => node.status.get());
34
25
  }
35
- /** The runtime's project state (`node.project()`, WP §3.5); `undefined` between sessions or before the first snapshot. */
26
+ /** The runtime's project state (`node.project`, WP §3.5); `undefined` between sessions or before the first snapshot. */
36
27
  export function useProject() {
37
28
  const node = useSynapse();
38
- return useSyncExternalStore(useCallback((cb) => node.watchProject(() => cb()), [node]), () => node.project(), () => node.project());
29
+ return useSyncExternalStore(useCallback((cb) => node.project.onChange(() => cb()), [node]), () => node.project.get(), () => node.project.get());
39
30
  }
40
- /** The runtime's catalogs as the current session last read them. */
31
+ /** The runtime's catalogs as the current session last read them (`node.catalog`). */
41
32
  export function useCatalog() {
42
33
  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);
34
+ return useSyncExternalStore(useCallback((cb) => node.catalog.onChange(() => cb()), [node]), () => node.catalog.get(), () => node.catalog.get());
45
35
  }
46
- /** The last `limit` broker notices (`node.onNotice`) since the component mounted, newest last. */
36
+ /** The last `limit` notices (`node.notices`) since the component mounted, newest last. */
47
37
  export function useNotices(limit = 20) {
48
38
  const node = useSynapse();
49
39
  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]);
40
+ useEffect(() => node.notices(n => setList(l => [...l, n].slice(-limit))), [node, limit]);
58
41
  return list;
59
42
  }
60
43
  const SKIP_STATE = { data: undefined, status: "pending", connection: "connecting", lastChangeAtUs: undefined };
@@ -87,7 +70,7 @@ export function useTopic(topic, opts = {}) {
87
70
  setView(v);
88
71
  return () => { v.close(); };
89
72
  }, [node, key]);
90
- const subscribe = useCallback((cb) => (view ? view.subscribe(cb) : () => { }), [view]);
73
+ const subscribe = useCallback((cb) => (view ? view.onChange(() => cb()) : () => { }), [view]);
91
74
  // The last result, with what it was made from: the same view state and selector give it back unchanged.
92
75
  const last = useRef(undefined);
93
76
  const get = useCallback(() => {
@@ -109,78 +92,75 @@ export function useTopic(topic, opts = {}) {
109
92
  }
110
93
  /** A service as a name: what the hooks key their memoisation on, so an inline contract (a new object every render) is fine. */
111
94
  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
- }
95
+ const IDLE = { run: undefined, status: "idle", data: undefined, error: undefined, updates: [] };
142
96
  /**
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.
97
+ * A call from a component, request/reply or streaming: `call(args)` starts it and returns its `Run`; `status`, `data`,
98
+ * `error`, `updates`, `latestUpdate` and `run` follow the latest call. `status` is TanStack's (`pending` covers the
99
+ * spec's `pending` and `running`; `error` covers `failed` and `canceled`: `run.status` and `error.code` tell which).
100
+ * The hook handles every run's outcome itself, so `call(args)` may be ignored (no unhandled rejection) or awaited
101
+ * (`await call(args)` resolves with the result or rejects with the error). `cancel(reason?)` cancels the latest run and
102
+ * resolves once it has ended, never rejecting: its outcome (`canceled`, or the result that won the race) is what
103
+ * `status`/`error`/`data` show. Every run still in flight is canceled with reason `"unmount"` when the component
104
+ * unmounts. `reset()` forgets the latest call (back to `idle`); a run in flight goes on, unfollowed.
146
105
  */
147
- export function useStart(svc, opts = {}) {
106
+ export function useCall(svc, opts = {}) {
148
107
  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);
108
+ const [state, setState] = useState(IDLE);
154
109
  const optsRef = useRef(opts);
155
110
  optsRef.current = opts;
156
111
  const svcRef = useRef(svc);
157
112
  svcRef.current = svc;
113
+ /** The run the state follows (the latest call's, until `reset`). */
158
114
  const runRef = useRef(undefined);
115
+ /** Every run started here and not yet ended: canceled on unmount. */
116
+ const inFlight = useRef(new Set());
159
117
  const name = nameOf(svc);
160
- const start = useCallback((args) => {
118
+ const call = useCallback((args) => {
161
119
  const { maxUpdates = 100, ...callOpts } = optsRef.current;
162
120
  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;
121
+ let r;
122
+ const onUpdate = (u) => {
123
+ if (r !== undefined && runRef.current === r) {
124
+ const mine = r;
125
+ setState(s => (s.run === mine ? { ...s, updates: s.updates.length < keep ? [...s.updates, u] : [...s.updates.slice(s.updates.length - keep + 1), u] } : s));
126
+ }
127
+ // Forwarded while the run is in flight here: not after unmount (its cleanup empties the set), even for a frame
128
+ // that lands before the cancel does. `reset()` leaves the run in flight, so it goes on forwarding.
129
+ if (r !== undefined && inFlight.current.has(r))
130
+ optsRef.current.onUpdate?.(u);
131
+ };
132
+ const run = node.call(svcRef.current, args, { ...callOpts, onUpdate });
133
+ r = run;
134
+ runRef.current = run;
135
+ inFlight.current.add(run);
136
+ setState({ run, status: "pending", data: undefined, error: undefined, updates: [] });
137
+ run.result.then(v => { inFlight.current.delete(run); if (runRef.current === run)
138
+ setState(s => ({ ...s, status: "success", data: v })); }, e => { inFlight.current.delete(run); if (runRef.current === run)
139
+ setState(s => ({ ...s, status: "error", error: toSynapseError(e) })); });
140
+ return run;
179
141
  // eslint-disable-next-line react-hooks/exhaustive-deps
180
142
  }, [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 };
143
+ const cancel = useCallback(async (reason) => {
144
+ const r = runRef.current;
145
+ if (!r)
146
+ return;
147
+ try {
148
+ await r.cancel(reason);
149
+ }
150
+ catch { /* the run's outcome is shown by status/error */ }
151
+ try {
152
+ await r.result;
153
+ }
154
+ catch { /* ended: shown by status/error */ }
155
+ }, []);
156
+ const reset = useCallback(() => { runRef.current = undefined; setState(IDLE); }, []);
157
+ useEffect(() => () => {
158
+ runRef.current = undefined;
159
+ for (const r of inFlight.current)
160
+ r.cancel("unmount").catch(() => { });
161
+ inFlight.current.clear();
162
+ }, []);
163
+ return { call, cancel, reset, run: state.run, status: state.status, data: state.data, error: state.error, updates: state.updates, latestUpdate: state.updates[state.updates.length - 1] };
184
164
  }
185
165
  const leases = new WeakMap();
186
166
  function acquireLease(node, svc, handler, opts) {
@@ -286,18 +266,17 @@ export function useStore(name, opts = {}) {
286
266
  return () => { mine = false; h?.close(); };
287
267
  // eslint-disable-next-line react-hooks/exhaustive-deps
288
268
  }, [node, key]);
289
- const subscribe = useCallback((cb) => (handle ? handle.subscribe(cb) : () => { }), [handle]);
269
+ const subscribe = useCallback((cb) => (handle ? handle.onChange(() => cb()) : () => { }), [handle]);
290
270
  // React re-renders when the snapshot changes: it holds the value and the handle's status, so a
291
271
  // 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.
272
+ // `handle`). `handle.status.get()` is the same object while unchanged.
294
273
  const last = useRef(undefined);
295
274
  const get = useCallback(() => {
296
275
  if (!handle)
297
276
  return undefined;
298
- const value = handle.value, status = handle.status;
277
+ const value = handle.get(), status = handle.status.get();
299
278
  const prev = last.current;
300
- if (!prev || prev.handle !== handle || prev.value !== value || !shallowEqual(prev.status, status))
279
+ if (!prev || prev.handle !== handle || prev.value !== value || prev.status !== status)
301
280
  last.current = { handle, value, status };
302
281
  return last.current;
303
282
  }, [handle]);
@@ -327,12 +306,25 @@ export function useScene() {
327
306
  }, [node]);
328
307
  return [scene, error];
329
308
  }
330
- export function useSceneEntity(scene, id, opts = {}) {
309
+ /**
310
+ * One entity of the scene: `scene.entity(id | { name })`, the merged entity (authored with its live overrides), or what
311
+ * `select` picks from it (`select` gets `undefined` while there is none). One plane alone (`view.authored.get()`,
312
+ * `view.live.get()` with its `stamp`) is read imperatively from the view, as a frame loop does. By name (`useSceneEntity(scene, { name: "tool" })`) it follows the name: the
313
+ * entity holding it now. The view reads the handle's copy
314
+ * of the scene and follows it only while the component is subscribed. `select` runs here with the
315
+ * selector of the latest render; a selected value deep-equal to the previous one keeps its reference,
316
+ * so a live pose that does not change what `select` picks does not re-render the component.
317
+ */
318
+ export function useSceneEntity(scene, ref, opts = {}) {
331
319
  const selectRef = useRef(opts.select);
332
320
  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]);
321
+ // An inline `{ name }` is a new object every render: the view is keyed on what it names.
322
+ const id = typeof ref === "string" ? ref : undefined, name = ref && typeof ref === "object" ? ref.name : undefined;
323
+ const view = useMemo(() => {
324
+ const target = id !== undefined && id !== "" ? id : name !== undefined ? { name } : undefined;
325
+ return scene && target !== undefined ? scene.entity(target) : undefined;
326
+ }, [scene, id, name]);
327
+ const subscribe = useCallback((cb) => (view ? view.onChange(() => cb()) : () => { }), [view]);
336
328
  const last = useRef(undefined);
337
329
  const get = useCallback(() => {
338
330
  const from = view?.get();
@@ -352,23 +344,14 @@ const EMPTY_TREE = [];
352
344
  /** The scene's hierarchy (`scene.tree()`), as its roots: the same array while the structure does not change; empty without a scene. */
353
345
  export function useSceneTree(scene) {
354
346
  const view = useMemo(() => scene?.tree(), [scene]);
355
- const subscribe = useCallback((cb) => (view ? view.subscribe(cb) : () => { }), [view]);
347
+ const subscribe = useCallback((cb) => (view ? view.onChange(() => cb()) : () => { }), [view]);
356
348
  const get = useCallback(() => view?.get() ?? EMPTY_TREE, [view]);
357
349
  return useSyncExternalStore(subscribe, get, get);
358
350
  }
359
- /** The scene handle's status (`scene.status`), re-rendering when a field changes; `undefined` without a scene. */
351
+ /** The scene handle's status (`scene.status`, a state value), re-rendering when a field changes; `undefined` without a scene. */
360
352
  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]);
353
+ const subscribe = useCallback((cb) => (scene ? scene.status.onChange(() => cb()) : () => { }), [scene]);
354
+ const get = useCallback(() => scene?.status.get(), [scene]);
372
355
  return useSyncExternalStore(subscribe, get, get);
373
356
  }
374
357
  export function useTasks(select) {
@@ -377,7 +360,7 @@ export function useTasks(select) {
377
360
  const selectRef = useRef(select);
378
361
  selectRef.current = select;
379
362
  const subscribe = useCallback((cb) => {
380
- const a = tasks.status.subscribe(cb), b = tasks.catalog.subscribe(cb);
363
+ const a = tasks.status.onChange(() => cb()), b = tasks.catalog.onChange(() => cb());
381
364
  return () => { a(); b(); };
382
365
  }, [tasks]);
383
366
  const verbs = useMemo(() => ({
@@ -387,7 +370,7 @@ export function useTasks(select) {
387
370
  const view = useRef(undefined);
388
371
  const last = useRef(undefined);
389
372
  const get = useCallback(() => {
390
- const status = tasks.status.value, catalog = tasks.catalog.value, synced = tasks.status.synced && tasks.catalog.synced;
373
+ const status = tasks.status.get(), catalog = tasks.catalog.get(), synced = tasks.status.synced && tasks.catalog.synced;
391
374
  const v = view.current;
392
375
  const from = v && v.status === status && v.catalog === catalog && v.synced === synced ? v : (view.current = { status, catalog, synced, ...verbs });
393
376
  const sel = selectRef.current;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapse-sdk/react",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Synapse SDK React hooks: connection, topics, calls, services, store.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -14,8 +14,8 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@synapse-sdk/core": "0.3.3",
18
- "@synapse-sdk/client": "0.3.3"
17
+ "@synapse-sdk/core": "0.3.4",
18
+ "@synapse-sdk/client": "0.3.4"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "react": ">=18"