@rainbow-robotics/plugin-ui 0.1.2-dev.8 → 1.0.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.
Files changed (61) hide show
  1. package/README.md +236 -75
  2. package/build-dist.mjs +55 -17
  3. package/dist/host-ui.d.ts +49 -0
  4. package/dist/host-ui.d.ts.map +1 -0
  5. package/dist/host-ui.js +33 -0
  6. package/dist/host-ui.js.map +7 -0
  7. package/dist/manifest.d.ts +20 -4
  8. package/dist/manifest.d.ts.map +1 -1
  9. package/dist/manifest.js +1 -1
  10. package/dist/manifest.js.map +2 -2
  11. package/dist/pluginData.d.ts +62 -0
  12. package/dist/pluginData.d.ts.map +1 -0
  13. package/dist/react.d.ts +3436 -36
  14. package/dist/react.js +5 -54
  15. package/dist/react.js.map +2 -2
  16. package/dist/sdk.d.ts +38 -2
  17. package/dist/sdk.d.ts.map +1 -1
  18. package/dist/sdk.js +29 -4
  19. package/dist/sdk.js.map +2 -2
  20. package/dist/viteHost.d.ts +46 -0
  21. package/dist/viteHost.d.ts.map +1 -0
  22. package/dist/viteHost.js +262 -0
  23. package/dist/viteHost.js.map +7 -0
  24. package/dist/ws.d.ts +53 -0
  25. package/dist/ws.d.ts.map +1 -1
  26. package/dist/ws.js +141 -0
  27. package/dist/ws.js.map +7 -0
  28. package/package.json +45 -21
  29. package/src/host-ui.ts +80 -0
  30. package/src/host.tsx +229 -308
  31. package/src/index.d.ts +1 -0
  32. package/src/manifest.ts +25 -4
  33. package/src/pluginData.tsx +232 -0
  34. package/src/react.tsx +9 -127
  35. package/src/sdk.ts +81 -13
  36. package/src/viteHost.ts +309 -0
  37. package/src/ws.ts +127 -20
  38. package/build-browser.mjs +0 -84
  39. package/dist/connect.d.ts +0 -11
  40. package/dist/connect.d.ts.map +0 -1
  41. package/dist/connect.js +0 -34
  42. package/dist/connect.js.map +0 -7
  43. package/dist/elements.d.ts +0 -195
  44. package/dist/elements.d.ts.map +0 -1
  45. package/dist/elements.js +0 -115
  46. package/dist/elements.js.map +0 -7
  47. package/dist/react.d.ts.map +0 -1
  48. package/dist-browser/@rainbow-robotics/plugin-ui/chunks/chunk-37HVFFJB.js +0 -1
  49. package/dist-browser/@rainbow-robotics/plugin-ui/chunks/chunk-4MGSRUAS.js +0 -1
  50. package/dist-browser/@rainbow-robotics/plugin-ui/chunks/chunk-Q6XGCIPE.js +0 -1
  51. package/dist-browser/@rainbow-robotics/plugin-ui/connect.js +0 -1
  52. package/dist-browser/@rainbow-robotics/plugin-ui/elements.js +0 -1
  53. package/dist-browser/@rainbow-robotics/plugin-ui/react.js +0 -1
  54. package/dist-browser/@rainbow-robotics/plugin-ui/sdk.js +0 -1
  55. package/dist-browser/vendor/react-dom-client.js +0 -1
  56. package/dist-browser/vendor/react-jsx-dev-runtime.js +0 -1
  57. package/dist-browser/vendor/react-jsx-runtime.js +0 -1
  58. package/dist-browser/vendor/react-runtime.js +0 -65
  59. package/dist-browser/vendor/react.js +0 -1
  60. package/src/connect.ts +0 -62
  61. package/src/elements.ts +0 -257
@@ -0,0 +1,232 @@
1
+ /// <reference types="vite/client" />
2
+ /**
3
+ * Plugin data seam (host-side). Resolves where each plugin's UI/assets load
4
+ * from. `muscat-pk dev` sets `window.__RB_PREVIEW_PLUGIN_ID__`; that id routes
5
+ * to preview-host, everything else to the installed-plugin back end, so both
6
+ * coexist. The host passes its dialog/toast as `hostServices`, registered via
7
+ * setHostServices (see ./host-ui).
8
+ */
9
+ import {
10
+ createContext,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useState,
15
+ type PropsWithChildren,
16
+ } from "react";
17
+ import type { PluginManifest } from "./manifest";
18
+ // Package specifier, not a relative path — see host.tsx for why (source vs dist
19
+ // host-ui would be two `services` singletons).
20
+ import {
21
+ setHostServices,
22
+ type PluginUIServices,
23
+ } from "@rainbow-robotics/plugin-ui/host-ui";
24
+
25
+ /**
26
+ * Where/how to load a plugin's UI module — no iframe involved, the host
27
+ * mounts it directly.
28
+ *
29
+ * - "dev": the plugin's own Vite dev server (muscat-pk dev). `bootstrapUrl` is
30
+ * a plain ESM module exposing `mount(container, rb, hostServices)`; react
31
+ * and plugin-ui resolve from that dev server's own module graph, so this
32
+ * mount is a fully self-consistent, independently-rooted React tree.
33
+ * - "esm": a built plugin (`muscat-pk build` / robot runtime), a plain ESM
34
+ * module the host dynamically imports. react / rb-components / plugin-ui
35
+ * context modules are left external as bare specifiers, resolved by the host
36
+ * page's import map to the host's own already-loaded instances — so it mounts
37
+ * as a genuine nested React tree with working Context (RbContext, host-ui)
38
+ * and no duplicate React instance.
39
+ */
40
+ export type PluginModuleSource =
41
+ | { kind: "dev"; bootstrapUrl: string }
42
+ | { kind: "esm"; entryUrl: string };
43
+
44
+ export interface PluginDataSource {
45
+ /** Preview plugin manifest (only while a dev plugin is being previewed). */
46
+ usePluginManifest: () => { data: PluginManifest | null; isLoading: boolean };
47
+ /** Where/how to load a plugin's UI module for a given UI entry. */
48
+ getModuleSource: (pluginId: string, ui: string) => PluginModuleSource;
49
+ /** URL of a plugin asset (e.g. an icon) by its manifest-relative path. */
50
+ getAssetUrl: (pluginId: string, assetPath: string) => string;
51
+ /** Socket.IO server URL the plugin connects to. */
52
+ getSocketUrl: (pluginId: string) => string;
53
+ }
54
+
55
+ const PluginDataContext = createContext<PluginDataSource | null>(null);
56
+
57
+ export function usePluginData(): PluginDataSource {
58
+ const ctx = useContext(PluginDataContext);
59
+ if (!ctx) {
60
+ throw new Error("usePluginData must be used within a PluginDataProvider");
61
+ }
62
+ return ctx;
63
+ }
64
+
65
+ /* -------------------------------------------------------------------------- */
66
+ /* Preview (preview-host: the developer's locally-built plugin) */
67
+ /* -------------------------------------------------------------------------- */
68
+
69
+ type PreviewWindow = Window & {
70
+ /** Plugin id under `muscat-pk dev` — requests for it route to preview-host. */
71
+ __RB_PREVIEW_PLUGIN_ID__?: string;
72
+ /** Base URL preview-host serves the dev plugin's manifest/assets from. */
73
+ __RB_PLUGIN_PREVIEW_BASE__?: string;
74
+ /** Where the plugin's OWN Vite dev server lives (distinct from the above —
75
+ * that's preview-host's own static route; this is the plugin's dev server,
76
+ * which serves its bootstrap module for direct mounting). */
77
+ __RB_PLUGIN_DEV_SERVER_URL__?: string;
78
+ /** Override URL for the plugin manifest. */
79
+ __RB_PLUGIN_PREVIEW_MANIFEST__?: string;
80
+ /** Socket.IO URL — injected by muscat-pk CLI when starting preview. */
81
+ __RB_PLUGIN_PREVIEW_SOCKET_URL__?: string;
82
+ };
83
+
84
+ const win = (): PreviewWindow | undefined =>
85
+ typeof window !== "undefined" ? (window as PreviewWindow) : undefined;
86
+
87
+ const isViteDev = import.meta.env.DEV;
88
+
89
+ /** The plugin id being previewed via `muscat-pk dev`, if any. */
90
+ function previewPluginId(): string | undefined {
91
+ return (
92
+ win()?.__RB_PREVIEW_PLUGIN_ID__ ||
93
+ (isViteDev ? "com.example.demo-plugin" : undefined)
94
+ );
95
+ }
96
+
97
+ /** Whether a given plugin id is the one being developed locally. */
98
+ function isPreviewPlugin(pluginId: string): boolean {
99
+ const id = previewPluginId();
100
+ return !!id && id === pluginId;
101
+ }
102
+
103
+ /** Where preview-host serves the dev plugin's manifest/assets. */
104
+ function previewBase(): string {
105
+ const base =
106
+ win()?.__RB_PLUGIN_PREVIEW_BASE__ ||
107
+ (isViteDev ? "http://localhost:4180/plugin-dev" : "/plugin-dev");
108
+ return base.replace(/\/$/, "");
109
+ }
110
+
111
+ /** Where the plugin's own Vite dev server lives (for the bootstrap module). */
112
+ function devServerUrl(): string {
113
+ const base =
114
+ win()?.__RB_PLUGIN_DEV_SERVER_URL__ ||
115
+ (isViteDev ? "http://localhost:4181" : win()?.location.origin || "");
116
+ return base.replace(/\/$/, "");
117
+ }
118
+
119
+ function previewManifestUrl(): string {
120
+ return (
121
+ win()?.__RB_PLUGIN_PREVIEW_MANIFEST__ || `${previewBase()}/manifest.json`
122
+ );
123
+ }
124
+
125
+ function previewSocketUrl(): string {
126
+ return win()?.__RB_PLUGIN_PREVIEW_SOCKET_URL__ || "ws://127.0.0.1:10000";
127
+ }
128
+
129
+ // Shared, deduped fetch — several components read the manifest, so a single
130
+ // module-level promise avoids duplicate requests (react-query is not needed).
131
+ let manifestPromise: Promise<PluginManifest> | null = null;
132
+ function loadPreviewManifest(): Promise<PluginManifest> {
133
+ if (!manifestPromise) {
134
+ manifestPromise = (async () => {
135
+ const url = previewManifestUrl();
136
+ const res = await fetch(url);
137
+ if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
138
+ return res.json() as Promise<PluginManifest>;
139
+ })();
140
+ }
141
+ return manifestPromise;
142
+ }
143
+
144
+ function usePreviewManifest(): {
145
+ data: PluginManifest | null;
146
+ isLoading: boolean;
147
+ } {
148
+ const enabled = !!previewPluginId();
149
+ const [data, setData] = useState<PluginManifest | null>(null);
150
+ const [isLoading, setIsLoading] = useState(enabled);
151
+
152
+ useEffect(() => {
153
+ if (!enabled) {
154
+ setData(null);
155
+ setIsLoading(false);
156
+ return;
157
+ }
158
+ let alive = true;
159
+ setIsLoading(true);
160
+ loadPreviewManifest()
161
+ .then((m) => alive && setData(m))
162
+ .finally(() => alive && setIsLoading(false));
163
+ return () => {
164
+ alive = false;
165
+ };
166
+ }, [enabled]);
167
+
168
+ return { data, isLoading };
169
+ }
170
+
171
+ /* -------------------------------------------------------------------------- */
172
+ /* Provider */
173
+ /* -------------------------------------------------------------------------- */
174
+
175
+ export interface PluginDataProviderProps {
176
+ /**
177
+ * The host's real dialog/toast, registered for plugins to use via
178
+ * `@rainbow-robotics/plugin-ui/host-ui`. The host builds these ONCE inside
179
+ * its own React tree (calling its real useDialog) and passes the resulting
180
+ * plain functions here — safe to call from a plugin's separate React
181
+ * instance (dev). See ./host-ui.
182
+ */
183
+ hostServices: PluginUIServices;
184
+ /** Socket.IO URL for installed (non-preview) plugins. */
185
+ wsUrl?: string;
186
+ /** URL prefix the host serves installed plugins from. */
187
+ liveBase?: string;
188
+ }
189
+
190
+ export function PluginDataProvider({
191
+ children,
192
+ hostServices,
193
+ wsUrl = "ws://127.0.0.1:10000",
194
+ liveBase = "http://localhost:3000/common/plugin",
195
+ }: PropsWithChildren<PluginDataProviderProps>) {
196
+ // Register in an effect (setHostServices is a side effect, not render-safe).
197
+ // Layout effect so it runs before children (a mounting PluginHost) read it.
198
+ useEffect(() => {
199
+ setHostServices(hostServices);
200
+ }, [hostServices]);
201
+
202
+ const source = useMemo<PluginDataSource>(() => {
203
+ const base = liveBase.replace(/\/$/, "");
204
+ return {
205
+ usePluginManifest: usePreviewManifest,
206
+ getModuleSource: (pluginId, ui) =>
207
+ isPreviewPlugin(pluginId)
208
+ ? {
209
+ kind: "dev",
210
+ bootstrapUrl: `${devServerUrl()}/@id/__x00__rb-bootstrap:${ui}`,
211
+ }
212
+ : {
213
+ kind: "esm",
214
+ // The plugin's built ESM entry for this ui — bare externals
215
+ // inside are resolved by the host page's import map.
216
+ entryUrl: `${base}/${pluginId}/${ui}.js`,
217
+ },
218
+ getAssetUrl: (pluginId, assetPath) =>
219
+ isPreviewPlugin(pluginId)
220
+ ? `${previewBase()}/${assetPath}`
221
+ : `${base}/${pluginId}/${assetPath}`,
222
+ getSocketUrl: (pluginId) =>
223
+ isPreviewPlugin(pluginId) ? previewSocketUrl() : wsUrl,
224
+ };
225
+ }, [wsUrl, liveBase]);
226
+
227
+ return (
228
+ <PluginDataContext.Provider value={source}>
229
+ {children}
230
+ </PluginDataContext.Provider>
231
+ );
232
+ }
package/src/react.tsx CHANGED
@@ -1,131 +1,13 @@
1
1
  /**
2
- * Author bindings — the React components a plugin author imports from
3
- * `@rainbow-robotics/plugin-ui/react`.
2
+ * Author component surface — re-exports the @repo/rb-components new_components
3
+ * barrel, plus the sdk hooks.
4
4
  *
5
- * These are *name tags*: they render rb-* custom elements into the sandbox's
6
- * remote DOM. The real rb-components are rendered by the host (see ./host).
7
- * Event names from each element's `remoteEvents` surface as `onX` props
8
- * (e.g. `change` → `onChange`) carrying a `RemoteEvent` whose `detail` holds
9
- * the payload.
5
+ * import { RBSlider, RBLabelButton, useRb } from "@rainbow-robotics/plugin-ui/react";
6
+ *
7
+ * The runtime import-map shim (./viteHost.ts) reads this module's exports
8
+ * rather than a hardcoded list, so the two stay in sync.
10
9
  */
11
- import { createRemoteComponent } from "@remote-dom/react";
12
- import type { ComponentType, ReactNode, Ref } from "react";
13
- import {
14
- RbSliderElement,
15
- RbButtonElement,
16
- RbIconButtonElement,
17
- RbTextElement,
18
- RbTextInputElement,
19
- RbSwitchElement,
20
- RbSelectElement,
21
- type RbButtonProps,
22
- type RbIconButtonProps,
23
- type RbSelectProps,
24
- type RbSliderProps,
25
- type RbSwitchProps,
26
- type RbTextInputProps,
27
- type RbTextProps,
28
- } from "./elements";
29
-
30
- // NOTE: createRemoteComponent only treats a prop as an event listener when it is
31
- // declared in `eventProps`; it does NOT auto-derive from the element's
32
- // `remoteEvents`. These maps MUST mirror the host's eventProps (see ./host).
33
- //
34
- // `eventProps` is typed against the element's *typed* EventListeners generic.
35
- // Fully typing those generics would also force typing every `remoteProperties`
36
- // shape on the classes (remote-dom itself uses @ts-expect-error around this), so
37
- // we cast the event maps here. The values are verified by the demo e2e, and the
38
- // returned components keep their element-derived prop types for plugin authors.
39
- type EventMap = Record<string, { event: string }>;
40
- const events = (map: EventMap) => ({ eventProps: map }) as never;
41
-
42
- type NoEventProps = Record<never, never>;
43
-
44
- type RemoteElementComponentProps<
45
- Element,
46
- Props,
47
- EventProps = NoEventProps,
48
- > = Props &
49
- EventProps & {
50
- children?: ReactNode;
51
- ref?: Ref<Element>;
52
- slot?: string;
53
- };
54
-
55
- type RemoteElementComponent<
56
- Element,
57
- Props,
58
- EventProps = NoEventProps,
59
- > = ComponentType<RemoteElementComponentProps<Element, Props, EventProps>>;
60
-
61
- export type RbSliderChangeEvent = { detail: number };
62
- export type RbTextInputEvent = { detail: string };
63
- export type RbSwitchChangeEvent = { detail: boolean };
64
- export type RbSelectChangeEvent = { detail: string };
65
-
66
- export interface RbSliderEventProps {
67
- onChange?: (event: RbSliderChangeEvent) => void;
68
- }
69
-
70
- export interface RbPressEventProps {
71
- onPress?: () => void;
72
- }
73
-
74
- export interface RbTextInputEventProps {
75
- onInput?: (event: RbTextInputEvent) => void;
76
- }
77
-
78
- export interface RbSwitchEventProps {
79
- onChange?: (event: RbSwitchChangeEvent) => void;
80
- }
81
-
82
- export interface RbSelectEventProps {
83
- onChange?: (event: RbSelectChangeEvent) => void;
84
- }
85
-
86
- export const Slider = createRemoteComponent(
87
- "rb-slider",
88
- RbSliderElement,
89
- events({ onChange: { event: "change" } }),
90
- ) as RemoteElementComponent<RbSliderElement, RbSliderProps, RbSliderEventProps>;
91
- export const Button = createRemoteComponent(
92
- "rb-button",
93
- RbButtonElement,
94
- events({ onPress: { event: "press" } }),
95
- ) as RemoteElementComponent<RbButtonElement, RbButtonProps, RbPressEventProps>;
96
- export const IconButton = createRemoteComponent(
97
- "rb-icon-button",
98
- RbIconButtonElement,
99
- events({ onPress: { event: "press" } }),
100
- ) as RemoteElementComponent<
101
- RbIconButtonElement,
102
- RbIconButtonProps,
103
- RbPressEventProps
104
- >;
105
- export const Text = createRemoteComponent(
106
- "rb-text",
107
- RbTextElement,
108
- ) as RemoteElementComponent<RbTextElement, RbTextProps>;
109
- export const TextInput = createRemoteComponent(
110
- "rb-text-input",
111
- RbTextInputElement,
112
- events({ onInput: { event: "input" } }),
113
- ) as RemoteElementComponent<
114
- RbTextInputElement,
115
- RbTextInputProps,
116
- RbTextInputEventProps
117
- >;
118
- export const Switch = createRemoteComponent(
119
- "rb-switch",
120
- RbSwitchElement,
121
- events({ onChange: { event: "change" } }),
122
- ) as RemoteElementComponent<RbSwitchElement, RbSwitchProps, RbSwitchEventProps>;
123
- export const Select = createRemoteComponent(
124
- "rb-select",
125
- RbSelectElement,
126
- events({ onChange: { event: "change" } }),
127
- ) as RemoteElementComponent<RbSelectElement, RbSelectProps, RbSelectEventProps>;
10
+ export * from "@repo/rb-components/new_components";
128
11
 
129
- export { useRb } from "./sdk";
130
- export type { RbSdk, RbNotifyType } from "./sdk";
131
- export type { RbSelectOption } from "./elements";
12
+ export { useRb, useRbFormData, useRbSave } from "./sdk.js";
13
+ export type { RbSdk } from "./sdk";
package/src/sdk.ts CHANGED
@@ -1,21 +1,26 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
1
2
  /**
2
- * Plugin runtime SDK — the surface a plugin author calls from inside the sandbox.
3
+ * Plugin runtime SDK (`useRb()`).
3
4
  *
4
- * Zenoh methods (pub/sub/action/serve) talk directly to the Zenoh router via
5
- * WebSocket. Topics are auto-namespaced to plugin/{pluginId}/... to prevent
6
- * cross-plugin collisions. A leading "/" bypasses namespacing (absolute path).
7
- *
8
- * notify() is still host-mediated (goes through the Muscat shell).
5
+ * pub/sub/action/serve talk to the Zenoh router over WebSocket. Topics are
6
+ * namespaced to {pluginId}/... ; a leading "/" makes the path absolute.
7
+ * alert/confirm/prompt/toast delegate to the host.
9
8
  */
10
- import { createContext, useContext } from "react";
11
- import type { WsSubscriber as Subscriber, WsQueryable as Queryable } from "./ws";
9
+ import { createContext, useContext, useEffect, useRef } from "react";
10
+ import type {
11
+ WsSubscriber as Subscriber,
12
+ WsQueryable as Queryable,
13
+ } from "./ws";
14
+ // Type-only import — no runtime coupling to host-ui.ts (which itself has no
15
+ // dependency back on sdk.ts, so this stays one-directional).
16
+ import type { PluginUIServices } from "./host-ui";
12
17
 
13
18
  export type RbNotifyType = "info" | "success" | "warning" | "error";
14
19
 
15
20
  export interface RbSdk {
16
21
  /**
17
22
  * Publish JSON payload to a topic.
18
- * "sensor/data" → plugin/{pluginId}/sensor/data
23
+ * "sensor/data" → {pluginId}/sensor/data
19
24
  * "/broadcast/x" → broadcast/x (absolute)
20
25
  */
21
26
  pub: <P = any>(topic: string, payload: P) => Promise<void>;
@@ -42,7 +47,29 @@ export interface RbSdk {
42
47
  ) => Promise<Queryable>;
43
48
 
44
49
  /** Show a transient notification through the host's toast system. */
45
- toast: (message: string, type?: RbNotifyType) => void;
50
+ toast: PluginUIServices["toast"];
51
+
52
+ /** Show a host alert dialog. */
53
+ alert: PluginUIServices["alert"];
54
+ /** Show a host confirm dialog. Resolves true/false. */
55
+ confirm: PluginUIServices["confirm"];
56
+ /** Show a host prompt dialog. Resolves the entered value, or undefined if cancelled. */
57
+ prompt: PluginUIServices["prompt"];
58
+
59
+ /**
60
+ * Host-provided initial data for this mount — e.g. the previously-saved
61
+ * values when the host re-opens a form. `undefined` for a fresh mount.
62
+ * Prefer the `useRbFormData()` hook over reading this directly.
63
+ */
64
+ data?: unknown;
65
+
66
+ /**
67
+ * Register a getter the host calls (synchronously) to collect this plugin's
68
+ * save payload — e.g. when the host's Save button is clicked. The returned
69
+ * value must be JSON-serialisable (the host persists it). Returns an
70
+ * unregister fn. Prefer the `useRbSave()` hook.
71
+ */
72
+ onSave: (getPayload: () => unknown) => () => void;
46
73
  }
47
74
 
48
75
  const noopSdk: RbSdk = {
@@ -57,13 +84,29 @@ const noopSdk: RbSdk = {
57
84
  console.warn("[plugin-ui] rb.action called outside of a host context");
58
85
  return undefined as R;
59
86
  },
60
- serve: async <P = any, R = any>(_topic?: string, _handler?: (payload?: P) => R) => {
87
+ serve: async <P = any, R = any>(
88
+ _topic?: string,
89
+ _handler?: (payload?: P) => R,
90
+ ) => {
61
91
  console.warn("[plugin-ui] rb.serve called outside of a host context");
62
92
  return { undeclare: async () => {} } as unknown as Queryable;
63
93
  },
64
- toast: (message) => {
65
- console.warn("[plugin-ui] rb.toast (no host):", message);
94
+ toast: (params) => {
95
+ console.warn("[plugin-ui] rb.toast (no host):", params);
96
+ },
97
+ alert: async (_) => {
98
+ console.warn("[plugin-ui] rb.alert called outside of a host context");
99
+ },
100
+ confirm: async (_) => {
101
+ console.warn("[plugin-ui] rb.confirm called outside of a host context");
102
+ return false;
103
+ },
104
+ prompt: async (_) => {
105
+ console.warn("[plugin-ui] rb.prompt called outside of a host context");
106
+ return undefined;
66
107
  },
108
+ data: undefined,
109
+ onSave: () => () => {},
67
110
  };
68
111
 
69
112
  export const RbContext = createContext<RbSdk>(noopSdk);
@@ -72,3 +115,28 @@ export const RbContext = createContext<RbSdk>(noopSdk);
72
115
  export function useRb(): RbSdk {
73
116
  return useContext(RbContext);
74
117
  }
118
+
119
+ /**
120
+ * Read the host-provided initial data for this mount (e.g. previously-saved
121
+ * form values). `undefined` for a fresh mount.
122
+ *
123
+ * const saved = useRbFormData<MyArgs>();
124
+ * const methods = useForm({ defaultValues: saved ?? DEFAULTS });
125
+ */
126
+ export function useRbFormData<T = unknown>(): T | undefined {
127
+ return useRb().data as T | undefined;
128
+ }
129
+
130
+ /**
131
+ * Register a getter the host calls to collect this plugin's save payload when
132
+ * the host's Save is clicked. The latest `getPayload` is always used, so it can
133
+ * close over current state. The returned value must be JSON-serialisable.
134
+ *
135
+ * useRbSave(() => ({ args: getValues(), summary: buildSummary() }));
136
+ */
137
+ export function useRbSave<T = unknown>(getPayload: () => T): void {
138
+ const rb = useRb();
139
+ const ref = useRef(getPayload);
140
+ ref.current = getPayload;
141
+ useEffect(() => rb.onSave(() => ref.current()), [rb]);
142
+ }