@aaqu/fromcubes-portal-react 0.1.0-alpha.18 → 0.1.0-alpha.19

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 (2) hide show
  1. package/nodes/portal-react.js +120 -34
  2. package/package.json +1 -1
@@ -45,6 +45,20 @@ module.exports = function (RED) {
45
45
  }
46
46
  const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
47
47
 
48
+ // Per-portal set of component names the portal depends on (needed set from last rebuild,
49
+ // including transitive deps). Lets component changes target only portals that use them.
50
+ if (!RED.settings.fcPortalNeeded) {
51
+ RED.settings.fcPortalNeeded = {};
52
+ }
53
+ const portalNeeded = RED.settings.fcPortalNeeded;
54
+
55
+ // Per-portal raw user JSX code string. Used as fallback to detect references to
56
+ // newly-added components that haven't been in a `needed` set yet.
57
+ if (!RED.settings.fcPortalCode) {
58
+ RED.settings.fcPortalCode = {};
59
+ }
60
+ const portalCode = RED.settings.fcPortalCode;
61
+
48
62
  // Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
49
63
  if (!RED.settings.fcEndpointOwners) {
50
64
  RED.settings.fcEndpointOwners = {};
@@ -57,23 +71,77 @@ module.exports = function (RED) {
57
71
  }
58
72
  const compNameOwners = RED.settings.fcCompNameOwners;
59
73
 
60
- // Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
61
- // Yields event loop between builds so HTTP server stays responsive
74
+ // Debounced selective rebuild: coalesces multiple component changes into one pass.
75
+ // Yields event loop between builds so HTTP server stays responsive.
62
76
  let _rebuildTimer = null;
63
- function scheduleRebuildAll() {
77
+ const _dirtyComps = new Set();
78
+ let _rebuildAllPending = false;
79
+
80
+ // Startup gate: on first process start, Node-RED constructs portal/component nodes
81
+ // sequentially over a window longer than the 50ms debounce. Without gating, an early
82
+ // flush rebuilds a portal, then a late component registration triggers a second
83
+ // rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
84
+ // collapses to exactly one rebuild pass.
85
+ let _startupPhase = true;
86
+ function _endStartupPhase() {
87
+ if (!_startupPhase) return;
88
+ _startupPhase = false;
89
+ if (_rebuildAllPending || _dirtyComps.size > 0) {
90
+ if (_rebuildTimer) { clearTimeout(_rebuildTimer); _rebuildTimer = null; }
91
+ _flushRebuild();
92
+ }
93
+ }
94
+ try {
95
+ if (RED.events && typeof RED.events.once === "function") {
96
+ RED.events.once("flows:started", _endStartupPhase);
97
+ }
98
+ } catch (e) { RED.log.trace("[portal-react] events.once: " + e.message); }
99
+ // Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
100
+ setTimeout(_endStartupPhase, 2000).unref?.();
101
+
102
+ function _armRebuild() {
103
+ if (_startupPhase) return; // gated — _endStartupPhase will flush
64
104
  if (_rebuildTimer) clearTimeout(_rebuildTimer);
65
- _rebuildTimer = setTimeout(() => {
66
- _rebuildTimer = null;
67
- const fns = Object.values(rebuildCallbacks);
68
- let i = 0;
69
- function next() {
70
- if (i >= fns.length) return;
71
- try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
72
- i++;
73
- if (i < fns.length) setImmediate(next);
105
+ _rebuildTimer = setTimeout(_flushRebuild, 50);
106
+ }
107
+ function scheduleRebuildAll() {
108
+ _rebuildAllPending = true;
109
+ _armRebuild();
110
+ }
111
+ function scheduleRebuildUsing(compName) {
112
+ if (!compName) return;
113
+ _dirtyComps.add(compName);
114
+ _armRebuild();
115
+ }
116
+ function _flushRebuild() {
117
+ _rebuildTimer = null;
118
+ const all = _rebuildAllPending;
119
+ const dirty = new Set(_dirtyComps);
120
+ _rebuildAllPending = false;
121
+ _dirtyComps.clear();
122
+
123
+ const targetIds = new Set();
124
+ for (const nodeId of Object.keys(rebuildCallbacks)) {
125
+ if (all) { targetIds.add(nodeId); continue; }
126
+ const used = portalNeeded[nodeId];
127
+ const raw = portalCode[nodeId] || "";
128
+ for (const name of dirty) {
129
+ if ((used && used.has(name)) || raw.includes(name)) {
130
+ targetIds.add(nodeId);
131
+ break;
132
+ }
74
133
  }
75
- next();
76
- }, 50);
134
+ }
135
+
136
+ const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
137
+ let i = 0;
138
+ function next() {
139
+ if (i >= fns.length) return;
140
+ try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
141
+ i++;
142
+ if (i < fns.length) setImmediate(next);
143
+ }
144
+ next();
77
145
  }
78
146
 
79
147
  // ── Load modules ─────────────────────────────────────────────
@@ -135,20 +203,24 @@ module.exports = function (RED) {
135
203
  }
136
204
  compNameOwners[compName] = node.id;
137
205
 
138
- registry[compName] = {
139
- code: config.compCode || "",
140
- };
206
+ const newCode = config.compCode || "";
207
+ const prevCode = registry[compName]?.code;
208
+ registry[compName] = { code: newCode };
141
209
 
142
210
  node.status({ fill: "green", shape: "dot", text: compName });
143
211
 
144
- // Trigger re-transpile on all portal-react nodes (debounced across all component registrations)
145
- scheduleRebuildAll();
212
+ // Only rebuild portals that reference this component, and only if the code actually changed.
213
+ if (prevCode !== newCode) {
214
+ scheduleRebuildUsing(compName);
215
+ }
146
216
 
147
217
  node.on("close", function (removed, done) {
148
218
  if (compNameOwners[compName] === node.id) {
149
219
  delete compNameOwners[compName];
150
220
  }
151
221
  delete registry[compName];
222
+ // Portals depending on this component must rebuild (topology changed or name resolution breaks).
223
+ scheduleRebuildUsing(compName);
152
224
  if (done) done();
153
225
  });
154
226
  }
@@ -268,6 +340,10 @@ module.exports = function (RED) {
268
340
  }
269
341
  }
270
342
 
343
+ // Remember which components this portal depends on, so component changes
344
+ // can target only affected portals.
345
+ portalNeeded[nodeId] = new Set(needed);
346
+
271
347
  // Topological sort only needed components
272
348
  const entries = allEntries.filter(([n]) => needed.has(n));
273
349
  entries.sort((a, b) => {
@@ -416,25 +492,26 @@ module.exports = function (RED) {
416
492
  try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (e) { RED.log.trace("[portal-react] ws send transpile error: " + e.message); }
417
493
  });
418
494
  } else {
419
- node.status({
420
- fill: "green",
421
- shape: "dot",
422
- text: `built • ${endpoint}`,
423
- });
495
+ updateStatus();
424
496
  if (compiled.metafile) {
425
497
  const output = Object.values(compiled.metafile.outputs)[0];
426
498
  const sizes = output
427
499
  ? Object.entries(output.inputs)
428
500
  .map(([name, info]) => ({
429
- name: name.replace(/^.*node_modules\//, ""),
501
+ name: name
502
+ .replace(/^.*node_modules\//, "")
503
+ .replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
430
504
  bytes: info.bytesInOutput,
431
505
  }))
432
506
  .sort((a, b) => b.bytes - a.bytes)
433
507
  .slice(0, 5)
434
508
  : [];
435
- const totalKB = (compiled.js.length / 1024).toFixed(1);
509
+ const totalKB = Math.round(compiled.js.length / 1024);
510
+ const top = sizes
511
+ .map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
512
+ .join(" · ");
436
513
  node.log(
437
- `Bundle${cacheHit ? " (cached)" : ""}: ${totalKB}KB top: ${sizes.map((s) => `${s.name} (${(s.bytes / 1024).toFixed(1)}KB)`).join(", ")}`,
514
+ `[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
438
515
  );
439
516
  }
440
517
  }
@@ -491,11 +568,7 @@ module.exports = function (RED) {
491
568
  if (state && state.jsxHash === jsxHash) {
492
569
  state.css = css;
493
570
  state.cssHash = cssHash;
494
- node.status({
495
- fill: "green",
496
- shape: "dot",
497
- text: `built • ${endpoint}`,
498
- });
571
+ updateStatus();
499
572
  }
500
573
  });
501
574
  } catch (e) {
@@ -506,6 +579,8 @@ module.exports = function (RED) {
506
579
 
507
580
  // Register rebuild callback so library components can trigger re-transpile
508
581
  rebuildCallbacks[nodeId] = rebuild;
582
+ // Remember raw user JSX so selective rebuild can detect references to new components
583
+ portalCode[nodeId] = componentCode;
509
584
 
510
585
  // Initial build: debounced so all fc-portal-component nodes register first
511
586
  scheduleRebuildAll();
@@ -760,8 +835,10 @@ module.exports = function (RED) {
760
835
  wsServer = null;
761
836
  }
762
837
 
763
- // Unregister rebuild callback
838
+ // Unregister rebuild callback + selective-rebuild metadata
764
839
  delete rebuildCallbacks[nodeId];
840
+ delete portalNeeded[nodeId];
841
+ delete portalCode[nodeId];
765
842
 
766
843
  // Release endpoint ownership
767
844
  if (endpointOwners[endpoint] === nodeId) {
@@ -871,7 +948,12 @@ module.exports = function (RED) {
871
948
  const { name, code } = req.body || {};
872
949
  if (!isSafeName(name))
873
950
  return res.status(400).json({ error: "invalid name" });
874
- registry[name] = { code: code || "" };
951
+ const newCode = code || "";
952
+ const prevCode = registry[name]?.code;
953
+ registry[name] = { code: newCode };
954
+ if (prevCode !== newCode) {
955
+ scheduleRebuildUsing(name);
956
+ }
875
957
  res.json({ ok: true });
876
958
  });
877
959
 
@@ -879,7 +961,11 @@ module.exports = function (RED) {
879
961
  const name = req.params.name;
880
962
  if (!isSafeName(name))
881
963
  return res.status(400).json({ error: "invalid name" });
964
+ const existed = Object.prototype.hasOwnProperty.call(registry, name);
882
965
  delete registry[name];
966
+ if (existed) {
967
+ scheduleRebuildUsing(name);
968
+ }
883
969
  res.json({ ok: true });
884
970
  });
885
971
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-portal-react",
3
- "version": "0.1.0-alpha.18",
3
+ "version": "0.1.0-alpha.19",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "keywords": [
6
6
  "node-red",