@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 +32 -1
- package/nodes/lib/page-builder.js +57 -3
- package/nodes/lib/router.js +4 -1
- package/nodes/portal-react.html +20 -4
- package/nodes/portal-react.js +3 -16
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
|
|
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 };
|
package/nodes/lib/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/nodes/portal-react.html
CHANGED
|
@@ -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>
|
|
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 }) => {
|
|
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.
|
package/nodes/portal-react.js
CHANGED
|
@@ -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
|
|
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,
|