@synapse-sdk/react 0.3.0 → 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 +153 -1
- package/dist/index.d.ts +47 -33
- package/dist/index.js +121 -107
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
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()` (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
|
+
`useSceneStatus(scene)` and `useTasks(select?)`. Every hook reads a value the node keeps current; none keeps its own
|
|
7
|
+
copy.
|
|
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
|
+
|
|
99
|
+
## Tasks
|
|
100
|
+
|
|
101
|
+
`useTasks()` is one view: `status` (what runs, `node.tasks.status.get()`), `catalog` (what the runtime offers), `synced`,
|
|
102
|
+
and the four verbs; the view is the same object while none of the three values changed. `useTasks(select)` is what
|
|
103
|
+
`select` picks from that view, and the component re-renders only when the selection changes (an unchanged selection
|
|
104
|
+
keeps its object). A button that only runs and stops needs no subscription: it calls the verbs on the node.
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
function RunPill() {
|
|
108
|
+
const node = useSynapse();
|
|
109
|
+
const state = useTasks(t => t.status.project.state); // re-renders on the project run's state only
|
|
110
|
+
const [note, setNote] = useState<string>();
|
|
111
|
+
const run = () => node.tasks.runProjectTasks().then(
|
|
112
|
+
r => setNote(`${r.launched} running · ${r.passive} passive`),
|
|
113
|
+
e => setNote(isTasksRefusal(e) ? e.data.report.map(i => `${i.entity_name ?? i.instance}: ${i.reason}`).join("\n") : e.code));
|
|
114
|
+
if (state === "running" || state === "stopping")
|
|
115
|
+
return <button disabled={state === "stopping"} onClick={() => node.tasks.stopProjectTasks()}>{state === "stopping" ? "stopping" : "running · stop"}</button>;
|
|
116
|
+
return <><button disabled={state !== "stopped"} onClick={run}>{state === "starting" ? "starting" : "run"}</button>{note}</>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// a per-entity dot: undefined = not running
|
|
120
|
+
const sim = useTasks(t => t.status.project.tasks[entityId]?.sim?.state);
|
|
121
|
+
|
|
122
|
+
// the picker: every task the runtime offers, by address
|
|
123
|
+
const { catalog, synced } = useTasks();
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`useTasks()` works from the first render, connected or not: before a session `status` and `catalog` are empty and `synced` is false.
|
|
127
|
+
|
|
128
|
+
## 3D reads, HTML renders
|
|
129
|
+
|
|
130
|
+
3D never renders through React. A frame loop (`useFrame`, or any engine loop) reads the current value imperatively,
|
|
131
|
+
from a view made once, and compares with `===`: the values are structurally shared, so a part that did not change is
|
|
132
|
+
the same object and the loop can skip it. No React state, no re-render, no hook per entity. Hooks with `select` are
|
|
133
|
+
for the HTML side: they wake a component only when its selection changes.
|
|
134
|
+
|
|
135
|
+
| what | in the frame loop (no render) | hook for HTML (wakes on) |
|
|
136
|
+
|---|---|---|
|
|
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) |
|
|
138
|
+
| a topic | `const v = useMemo(() => node.view(t), [t])` (close it on unmount); `v.get().data` | `useTopic(t, { 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) |
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
function Arm({ scene, id }: { scene: SceneHandle; id: string }) {
|
|
144
|
+
const ref = useRef<THREE.Group>(null);
|
|
145
|
+
const view = useMemo(() => scene.entity(id), [scene, id]); // made once
|
|
146
|
+
const last = useRef<SceneEntity | undefined>(undefined);
|
|
147
|
+
useFrame(() => {
|
|
148
|
+
const e = view.get(); // the value now: no render
|
|
149
|
+
if (e === last.current || !e?.xform) return; // unchanged: the same object
|
|
150
|
+
last.current = e;
|
|
151
|
+
ref.current!.position.fromArray(e.xform.t ?? [0, 0, 0]);
|
|
152
|
+
});
|
|
153
|
+
return <group ref={ref} />;
|
|
154
|
+
}
|
|
155
|
+
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import type { Synapse, Catalog, ConnectionState,
|
|
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
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
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
|
|
52
|
-
|
|
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,22 +79,40 @@ 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
|
|
87
|
-
*
|
|
88
|
-
* `
|
|
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,
|
|
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
|
|
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;
|
|
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). */
|
|
98
|
+
export interface TasksView {
|
|
99
|
+
readonly status: TasksStatus;
|
|
100
|
+
/** By address. */
|
|
101
|
+
readonly catalog: Readonly<Record<string, TaskCatalogEntry>>;
|
|
102
|
+
/** Both `status` and `catalog` current. */
|
|
103
|
+
readonly synced: boolean;
|
|
104
|
+
runRobotTasks(): Promise<TasksRun>;
|
|
105
|
+
stopRobotTasks(): Promise<void>;
|
|
106
|
+
runProjectTasks(): Promise<TasksRun>;
|
|
107
|
+
stopProjectTasks(): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The node's tasks: `status` (what runs), `catalog` (what the runtime offers) and `synced`, with the verbs, as one
|
|
111
|
+
* `TasksView`; or what `select` picks from it. The view is the same object while none of the three values changed.
|
|
112
|
+
* `select` runs here with the selector of the latest render; a selected value deep-equal to the previous one keeps its
|
|
113
|
+
* reference, so a change elsewhere (another task's restart, a catalog refresh) does not re-render the component.
|
|
114
|
+
* A component that only runs and stops needs no subscription: `useSynapse().tasks.runProjectTasks()`.
|
|
115
|
+
* Works from the first render, connected or not: before a session `status`/`catalog` are empty and `synced` is false.
|
|
116
|
+
*/
|
|
117
|
+
export declare function useTasks(): TasksView;
|
|
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
|
|
2
|
-
// through useSyncExternalStore from a client object (a view, a store handle, the node's
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
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
|
|
106
|
+
export function useCall(svc, opts = {}) {
|
|
148
107
|
const node = useSynapse();
|
|
149
|
-
const [
|
|
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
|
|
118
|
+
const call = useCallback((args) => {
|
|
161
119
|
const { maxUpdates = 100, ...callOpts } = optsRef.current;
|
|
162
120
|
const keep = Math.max(1, maxUpdates);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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) =>
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
|
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`
|
|
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.
|
|
277
|
+
const value = handle.get(), status = handle.status.get();
|
|
299
278
|
const prev = last.current;
|
|
300
|
-
if (!prev || prev.handle !== handle || prev.value !== value ||
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
const
|
|
335
|
-
const
|
|
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,22 +344,44 @@ 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.
|
|
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
|
|
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
|
-
|
|
353
|
+
const subscribe = useCallback((cb) => (scene ? scene.status.onChange(() => cb()) : () => { }), [scene]);
|
|
354
|
+
const get = useCallback(() => scene?.status.get(), [scene]);
|
|
355
|
+
return useSyncExternalStore(subscribe, get, get);
|
|
356
|
+
}
|
|
357
|
+
export function useTasks(select) {
|
|
358
|
+
const node = useSynapse();
|
|
359
|
+
const tasks = node.tasks;
|
|
360
|
+
const selectRef = useRef(select);
|
|
361
|
+
selectRef.current = select;
|
|
362
|
+
const subscribe = useCallback((cb) => {
|
|
363
|
+
const a = tasks.status.onChange(() => cb()), b = tasks.catalog.onChange(() => cb());
|
|
364
|
+
return () => { a(); b(); };
|
|
365
|
+
}, [tasks]);
|
|
366
|
+
const verbs = useMemo(() => ({
|
|
367
|
+
runRobotTasks: () => tasks.runRobotTasks(), stopRobotTasks: () => tasks.stopRobotTasks(),
|
|
368
|
+
runProjectTasks: () => tasks.runProjectTasks(), stopProjectTasks: () => tasks.stopProjectTasks(),
|
|
369
|
+
}), [tasks]);
|
|
370
|
+
const view = useRef(undefined);
|
|
362
371
|
const last = useRef(undefined);
|
|
363
|
-
const subscribe = useCallback((cb) => (scene ? scene.subscribe(cb) : () => { }), [scene]);
|
|
364
372
|
const get = useCallback(() => {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
373
|
+
const status = tasks.status.get(), catalog = tasks.catalog.get(), synced = tasks.status.synced && tasks.catalog.synced;
|
|
374
|
+
const v = view.current;
|
|
375
|
+
const from = v && v.status === status && v.catalog === catalog && v.synced === synced ? v : (view.current = { status, catalog, synced, ...verbs });
|
|
376
|
+
const sel = selectRef.current;
|
|
377
|
+
if (!sel)
|
|
378
|
+
return from;
|
|
379
|
+
const prev = last.current;
|
|
380
|
+
if (prev && prev.from === from && prev.select === sel)
|
|
381
|
+
return prev.out;
|
|
382
|
+
const out = replaceEqualDeep(prev?.out, sel(from));
|
|
383
|
+
last.current = { from, select: sel, out };
|
|
384
|
+
return out;
|
|
385
|
+
}, [tasks, verbs]);
|
|
372
386
|
return useSyncExternalStore(subscribe, get, get);
|
|
373
387
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapse-sdk/react",
|
|
3
|
-
"version": "0.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.
|
|
18
|
-
"@synapse-sdk/client": "0.3.
|
|
17
|
+
"@synapse-sdk/core": "0.3.4",
|
|
18
|
+
"@synapse-sdk/client": "0.3.4"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": ">=18"
|