@aaqu/fromcubes-react 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,7 +112,8 @@ Everything your component needs from the flow lives in one hook:
112
112
 
113
113
  ```jsx
114
114
  const {
115
- data, // last broadcast msg.payload from input wire (reactive)
115
+ data, // last msg.payload delivered to this tab from the input wire (reactive)
116
+ topic, // msg.topic of that same message, "" when it had none
116
117
  send, // send(payload, topic?) — emit msg on output wire
117
118
  user, // portal user object (when Portal Auth is enabled), or null
118
119
  portalClient, // unique session/tab ID assigned by server on connect
@@ -131,6 +132,36 @@ useEffect(() => { send({}, "init"); }, []);
131
132
 
132
133
  In the flow, catch `topic === "init"`, look up the state (per-user via `msg._client.userId` if needed) and reply — `msg._client` is already set on the inbound message, so the response returns as a unicast to exactly that tab.
133
134
 
135
+ ### Every message: `useNodeRedMessages()`
136
+
137
+ `data` and `topic` are React state: they hold only the **last** message. When two messages arrive before React renders — a flow answering two requests at once, a function node returning several msgs — React renders once and a `useEffect(..., [data])` sees only the last one; the earlier ones are silently lost. The same effect also does not re-fire when the same primitive payload arrives twice in a row.
138
+
139
+ For events and request → response protocols, use `useNodeRedMessages(handler)`. The handler is called once per incoming message, in order, as `(payload, { topic })`:
140
+
141
+ ```jsx
142
+ function App() {
143
+ const { send } = useNodeRed();
144
+ const [photos, setPhotos] = useState([]);
145
+ const [saving, setSaving] = useState(false);
146
+
147
+ useNodeRedMessages((payload, { topic }) => {
148
+ if (topic === "photos") setPhotos(payload);
149
+ else if (topic === "saved") setSaving(false);
150
+ });
151
+ useEffect(() => { send({}, "init"); }, []);
152
+ // ...
153
+ }
154
+ ```
155
+
156
+ - It pairs with the `send({}, "init")` pattern above: the hook subscribes in its own effect, which runs before a later `useEffect` in the same component sends the request, so the reply cannot slip past it.
157
+ - No replay: a component mounted later does **not** get an earlier message (unlike `data`, which starts from the last payload this tab received).
158
+ - The latest `handler` is always the one called — no need to memoize it. It unsubscribes on unmount.
159
+ - A handler that throws is logged to the console; other handlers and `useNodeRed()` still get the message.
160
+
161
+ Rule of thumb: `data` to **show current state**, `useNodeRedMessages` for **events**. (Underneath is the low-level `window.__NR.subscribeMessages(fn)`.)
162
+
163
+ If a portal already ships its own `fc-portal-utility` named `useNodeRedMessages` (written before the built-in existed), that utility keeps working and shadows the built-in — in every portal that references the name, since utilities are shared.
164
+
134
165
  ## Node configuration
135
166
 
136
167
  | Field | Purpose |
@@ -205,7 +205,11 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
205
205
  window.__NR = {
206
206
  _ws: null,
207
207
  _listeners: new Set(),
208
+ // Per-message listeners (useNodeRedMessages). Unlike _listeners they
209
+ // never get a replay of _lastData: each call is one real frame.
210
+ _msgListeners: new Set(),
208
211
  _lastData: null,
212
+ _lastTopic: '',
209
213
  _authRequired: false,
210
214
  _authListeners: new Set(),
211
215
  _retries: 0,
@@ -315,8 +319,17 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
315
319
  }
316
320
  }
317
321
  if (m.type === 'data') {
322
+ const topic = typeof m.topic === 'string' ? m.topic : '';
318
323
  this._lastData = m.payload;
319
- this._listeners.forEach(fn => fn(m.payload));
324
+ this._lastTopic = topic;
325
+ // Per-message listeners first, each isolated: React may
326
+ // coalesce the state updates below into one render, these
327
+ // see every frame.
328
+ const meta = { topic };
329
+ this._msgListeners.forEach(fn => {
330
+ try { fn(m.payload, meta); } catch (err) { console.error('useNodeRedMessages handler', err); }
331
+ });
332
+ this._listeners.forEach(fn => fn(m.payload, topic));
320
333
  }
321
334
  } catch (err) { console.error('WS parse', err); }
322
335
  };
@@ -336,10 +349,15 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
336
349
 
337
350
  subscribe(fn) {
338
351
  this._listeners.add(fn);
339
- if (this._lastData !== null) fn(this._lastData);
352
+ if (this._lastData !== null) fn(this._lastData, this._lastTopic);
340
353
  return () => this._listeners.delete(fn);
341
354
  },
342
355
 
356
+ subscribeMessages(fn) {
357
+ this._msgListeners.add(fn);
358
+ return () => this._msgListeners.delete(fn);
359
+ },
360
+
343
361
  subscribeAuth(fn) {
344
362
  this._authListeners.add(fn);
345
363
  if (this._authRequired) fn(true);
@@ -496,4 +514,40 @@ function buildErrorPage(title, error, wsPath) {
496
514
  </html>`;
497
515
  }
498
516
 
499
- module.exports = { buildPage, buildErrorPage };
517
+ /**
518
+ * The hooks every portal bundle gets, inserted after the React shorthand and
519
+ * before utilities (so a utility may call them). Browser code, kept here next
520
+ * to the `window.__NR` bridge it talks to.
521
+ *
522
+ * - `useNodeRed()` — `data`/`topic` are STATE: the last frame (one object,
523
+ * so the two never come from different frames). Two frames arriving
524
+ * before React renders collapse into one render, and an effect on `[data]`
525
+ * sees only the second.
526
+ * - `useNodeRedMessages(handler)` — one call per frame, `(payload, {topic})`,
527
+ * no replay of an earlier frame on mount. Assigned to `window` rather than
528
+ * declared: a portal that ships its own utility of that name (written before
529
+ * this existed) shadows it instead of colliding with it at bundle time.
530
+ */
531
+ const NR_HOOKS_SRC = [
532
+ "function useNodeRed() {",
533
+ " const [last, setLast] = React.useState(() => ({ data: window.__NR._lastData, topic: window.__NR._lastTopic }));",
534
+ " React.useEffect(() => window.__NR.subscribe((data, topic) => setLast({ data, topic: topic || '' })), []);",
535
+ " const [authRequired, setAuthRequired] = React.useState(window.__NR._authRequired);",
536
+ " React.useEffect(() => window.__NR.subscribeAuth(setAuthRequired), []);",
537
+ " const send = React.useCallback((payload, topic) => {",
538
+ " window.__NR.send(payload, topic);",
539
+ " }, []);",
540
+ " const user = window.__NR._user || null;",
541
+ " const portalClient = window.__NR._portalClient;",
542
+ " return { data: last.data, topic: last.topic, send, user, portalClient, authRequired };",
543
+ "}",
544
+ "window.useNodeRedMessages = function useNodeRedMessages(handler) {",
545
+ " const ref = React.useRef(handler);",
546
+ " React.useLayoutEffect(() => { ref.current = handler; });",
547
+ " React.useEffect(() => window.__NR.subscribeMessages((payload, meta) => {",
548
+ " if (typeof ref.current === 'function') ref.current(payload, meta);",
549
+ " }), []);",
550
+ "};",
551
+ ].join("\n");
552
+
553
+ module.exports = { buildPage, buildErrorPage, NR_HOOKS_SRC };
@@ -44,7 +44,10 @@
44
44
  function route(msg, ctx) {
45
45
  const { clients, userIndex, sendTo } = ctx;
46
46
  const target = msg && msg._client;
47
- const frame = JSON.stringify({ type: "data", payload: msg.payload });
47
+ // topic rides along only when it is a non-empty string — useNodeRed()
48
+ // ignores it, useNodeRedMessages() hands it to the handler.
49
+ const topic = msg && typeof msg.topic === "string" && msg.topic ? msg.topic : undefined;
50
+ const frame = JSON.stringify({ type: "data", payload: msg.payload, topic });
48
51
 
49
52
  let delivered = 0;
50
53
 
@@ -130,7 +130,8 @@
130
130
  const libContent = [
131
131
  "declare const React: any;",
132
132
  "declare const ReactDOM: any;",
133
- "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void; user: { userId: string; login?: string; displayName?: string; email?: string; role?: string; groups: string[] } | null; portalClient: string | null; authRequired: boolean };",
133
+ "declare function useNodeRed(): { data: any; topic: string; send: (payload: any, topic?: string) => void; user: { userId: string; login?: string; displayName?: string; email?: string; role?: string; groups: string[] } | null; portalClient: string | null; authRequired: boolean };",
134
+ "declare function useNodeRedMessages(handler: (payload: any, meta: { topic: string }) => void): void;",
134
135
  ].join("\n");
135
136
  console.log(PREFIX, "addExtraLib globals.d.ts");
136
137
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
@@ -2071,9 +2072,10 @@
2071
2072
  let headEditorInstance = null;
2072
2073
 
2073
2074
  const STARTER = [
2074
- "// useNodeRed() \u2192 { data, send, portalClient }",
2075
- "// data = last msg.payload from input wire",
2075
+ "// useNodeRed() \u2192 { data, topic, send, portalClient }",
2076
+ "// data = last msg.payload from input wire (topic = its msg.topic)",
2076
2077
  "// send(payload, topic?) = push msg to output wire",
2078
+ "// useNodeRedMessages((payload, { topic }) => ...) = every msg, in order",
2077
2079
  "// Components from fc-portal-component nodes are available by name.",
2078
2080
  "",
2079
2081
  "function App() {",
@@ -2719,6 +2721,8 @@
2719
2721
  <dl class="message-properties">
2720
2722
  <dt>payload <span class="property-type">any</span></dt>
2721
2723
  <dd>Sent to the open pages; read it with <code>useNodeRed().data</code>.</dd>
2724
+ <dt class="optional">topic <span class="property-type">string</span></dt>
2725
+ <dd>Sent along with the payload; read it with <code>useNodeRed().topic</code>.</dd>
2722
2726
  <dt class="optional">_client <span class="property-type">object</span></dt>
2723
2727
  <dd>
2724
2728
  Who gets it: <code>{ portalClient }</code> one browser tab,
@@ -2747,10 +2751,22 @@
2747
2751
  }</pre>
2748
2752
  <p>
2749
2753
  <code>useNodeRed()</code> gives <code>data</code> (the last payload,
2750
- <code>null</code> until one arrives), <code>send</code>, <code>user</code>
2754
+ <code>null</code> until one arrives), <code>topic</code> (that message's
2755
+ topic, <code>""</code> if none), <code>send</code>, <code>user</code>
2751
2756
  (the visitor, or <code>null</code>) and <code>portalClient</code> (this
2752
2757
  tab's id).
2753
2758
  </p>
2759
+ <p>
2760
+ <code>data</code> holds only the latest payload: messages that arrive
2761
+ faster than React renders are merged into one render. To handle every
2762
+ message (events, replies to a request), use
2763
+ <code>useNodeRedMessages</code> — it calls your function once per
2764
+ message, in order:
2765
+ </p>
2766
+ <pre>useNodeRedMessages((payload, { topic }) =&gt; {
2767
+ if (topic === "photos") setPhotos(payload);
2768
+ else if (topic === "saved") setSaving(false);
2769
+ });</pre>
2754
2770
  <p>
2755
2771
  <b>React Component</b> and <b>React Utility</b> nodes can be used here by
2756
2772
  name, without imports.
@@ -426,7 +426,7 @@ module.exports = function (RED) {
426
426
  for (const nodeId of Object.keys(rebuildCallbacks)) scheduleRebuildSelf(nodeId);
427
427
  }
428
428
  }
429
- const { buildPage, buildErrorPage } = require("./lib/page-builder");
429
+ const { buildPage, buildErrorPage, NR_HOOKS_SRC } = require("./lib/page-builder");
430
430
  const { createPortalPageHandler } = require("./lib/portal-page-route");
431
431
  const {
432
432
  extractUtilitySymbols,
@@ -879,21 +879,8 @@ module.exports = function (RED) {
879
879
  "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
880
880
  "const { createContext, memo, forwardRef, Fragment } = React;",
881
881
  "",
882
- "// ── useNodeRed hook ──",
883
- [
884
- "function useNodeRed() {",
885
- " const [data, setData] = React.useState(window.__NR._lastData);",
886
- " React.useEffect(() => window.__NR.subscribe(setData), []);",
887
- " const [authRequired, setAuthRequired] = React.useState(window.__NR._authRequired);",
888
- " React.useEffect(() => window.__NR.subscribeAuth(setAuthRequired), []);",
889
- " const send = React.useCallback((payload, topic) => {",
890
- " window.__NR.send(payload, topic);",
891
- " }, []);",
892
- " const user = window.__NR._user || null;",
893
- " const portalClient = window.__NR._portalClient;",
894
- " return { data, send, user, portalClient, authRequired };",
895
- "}",
896
- ].join("\n"),
882
+ "// ── useNodeRed hooks ──",
883
+ NR_HOOKS_SRC,
897
884
  "",
898
885
  "// ── Utilities (helpers / hooks / constants) ──",
899
886
  cleanUtilJsx,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-react",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Fromcubes React - React portals for Node-RED, built at deploy, with live WebSocket data binding",
5
5
  "type": "commonjs",
6
6
  "keywords": [