@aaqu/fromcubes-portal-react 0.1.0-alpha.26 → 0.1.0-alpha.28

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.
@@ -319,6 +319,32 @@ const REACT_BUILTIN_TAGS = new Set([
319
319
  const PASCAL_TAG_RE = /<\s*([A-Z][A-Za-z0-9_]*)/g;
320
320
  const RE_ESCAPE = /[.*+?^${}()|[\]\\]/g;
321
321
 
322
+ // Cached identifier-boundary regexes shared by every "does this code
323
+ // reference identifier X" scan (component deps, utility selection, dirty-
324
+ // portal matching, topological sort). `\b` breaks for names that start or
325
+ // end with `$` — a legal identifier char that is not a regex word char — so
326
+ // lookarounds on the [\w$] class are used instead. Cache is bounded: cleared
327
+ // wholesale past 5000 entries (long-running process with many renames).
328
+ const IDENT_RE_CACHE = new Map();
329
+
330
+ /**
331
+ * Return a cached RegExp matching `name` as a standalone JS identifier
332
+ * (not as a prefix/suffix/substring of a longer identifier).
333
+ *
334
+ * @param {string} name Identifier to match (component/utility/symbol name).
335
+ * @returns {RegExp}
336
+ */
337
+ function identifierRe(name) {
338
+ let re = IDENT_RE_CACHE.get(name);
339
+ if (!re) {
340
+ if (IDENT_RE_CACHE.size > 5000) IDENT_RE_CACHE.clear();
341
+ const escaped = name.replace(RE_ESCAPE, "\\$&");
342
+ re = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
343
+ IDENT_RE_CACHE.set(name, re);
344
+ }
345
+ return re;
346
+ }
347
+
322
348
  /**
323
349
  * Find PascalCase JSX tags in `userCode` that have no visible definition.
324
350
  *
@@ -411,6 +437,7 @@ module.exports.quickCheckSyntax = quickCheckSyntax;
411
437
  module.exports.formatEsbuildError = formatEsbuildError;
412
438
  module.exports.extractPortalUser = extractPortalUser;
413
439
  module.exports.findMissingComponentRefs = findMissingComponentRefs;
440
+ module.exports.identifierRe = identifierRe;
414
441
  module.exports.serveableHash = serveableHash;
415
442
  module.exports.hasFreshBuild = hasFreshBuild;
416
443
  module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
@@ -575,21 +602,24 @@ function createHelpers(RED) {
575
602
 
576
603
  /**
577
604
  * Bundle the user JSX (with utility/library/import code already concatenated)
578
- * into a minified IIFE. Pre-validates with `quickCheckSyntax` first to avoid
579
- * esbuild `buildSync` deadlocks on malformed input. The `react`/`react-dom`
580
- * alias points at this package's own copies so peer-dep packages share a
581
- * single React instance.
605
+ * into a minified IIFE. Pre-validates with `quickCheckSyntax` first so
606
+ * malformed input never reaches the bundler. Uses the async `esbuild.build`
607
+ * API a large bundle (three.js et al.) runs in esbuild's service process
608
+ * without blocking the Node-RED event loop. The `react`/`react-dom` alias
609
+ * points at this package's own copies so peer-dep packages share a single
610
+ * React instance.
582
611
  *
583
612
  * @param {string} jsx
584
- * @returns {TranspileResult}
613
+ * @returns {Promise<TranspileResult>}
585
614
  */
586
- function transpile(jsx) {
587
- // Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
615
+ async function transpile(jsx) {
616
+ // Pre-validate with transformSync (fast, no bundling) so syntax errors get
617
+ // clean line-numbered diagnostics before any resolution work
588
618
  const syntaxErr = quickCheckSyntax(jsx);
589
619
  if (syntaxErr) return { js: null, error: syntaxErr };
590
620
  // Syntax OK — bundle with full resolution
591
621
  try {
592
- const buildResult = esbuild.buildSync({
622
+ const buildResult = await esbuild.build({
593
623
  stdin: {
594
624
  contents: jsx,
595
625
  resolveDir: pkgRoot,
@@ -639,6 +669,7 @@ function createHelpers(RED) {
639
669
  isSafeName,
640
670
  validateSubPath,
641
671
  findMissingComponentRefs,
672
+ identifierRe,
642
673
  pkgRoot,
643
674
  userDir,
644
675
  cacheDir,
@@ -40,6 +40,7 @@ function createPortalPageHandler(opts) {
40
40
  } = opts;
41
41
 
42
42
  return async function portalPageHandler(req, res) {
43
+ let cssTimer = null;
43
44
  try {
44
45
  const state = pageState[endpoint];
45
46
  if (!state || state.building || !state.compiled) {
@@ -88,13 +89,16 @@ function createPortalPageHandler(opts) {
88
89
 
89
90
  const { cssHash } = await Promise.race([
90
91
  state.cssReady,
91
- new Promise((_, reject) =>
92
- setTimeout(
92
+ new Promise((_, reject) => {
93
+ cssTimer = setTimeout(
93
94
  () => reject(new Error("CSS generation timeout")),
94
95
  15000,
95
- ),
96
- ),
96
+ );
97
+ }),
97
98
  ]);
99
+ // Clear the losing timer — without this every request leaves a 15 s
100
+ // timer pending after the CSS promise wins the race.
101
+ clearTimeout(cssTimer);
98
102
  const user = state.portalAuth ? extractPortalUser(req.headers) : null;
99
103
  res
100
104
  .type("text/html")
@@ -111,6 +115,7 @@ function createPortalPageHandler(opts) {
111
115
  ),
112
116
  );
113
117
  } catch (e) {
118
+ clearTimeout(cssTimer);
114
119
  res
115
120
  .status(500)
116
121
  .type("text/html")
@@ -1,23 +1,74 @@
1
1
  const MAX_UTIL_CODE_BYTES = 1_000_000;
2
2
 
3
+ const DECL_RE = /^(?:export\s+)?(?:async\s+)?(function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
4
+
3
5
  /**
4
6
  * Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
5
7
  * names. Used for selective inclusion, symbol collision checks, and the
6
8
  * editor Utilities dialog. Oversize input is ignored so the regex cannot be
7
9
  * weaponized with multi-MB code.
8
10
  *
11
+ * Multi-declarator statements (`const a = 1, b = 2`) yield every name —
12
+ * initializer-internal commas are skipped via bracket/quote depth tracking.
13
+ * Destructuring declarations are not supported (never were).
14
+ *
9
15
  * @param {string} code
10
16
  * @returns {Set<string>}
11
17
  */
12
18
  function extractUtilitySymbols(code) {
13
19
  const names = new Set();
14
20
  if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
15
- const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
21
+ DECL_RE.lastIndex = 0;
16
22
  let m;
17
- while ((m = re.exec(code))) names.add(m[1]);
23
+ while ((m = DECL_RE.exec(code))) {
24
+ names.add(m[2]);
25
+ const kind = m[1];
26
+ if (kind === "const" || kind === "let" || kind === "var") {
27
+ collectExtraDeclarators(code, DECL_RE.lastIndex, names);
28
+ }
29
+ }
18
30
  return names;
19
31
  }
20
32
 
33
+ /**
34
+ * Walk a `const`/`let`/`var` statement from just past its first declared
35
+ * name and collect the names of any further comma-separated declarators.
36
+ * Commas inside `()`, `[]`, `{}` or string/template literals belong to the
37
+ * initializer expression and are ignored. Scanning stops at the first `;`
38
+ * (or unbalanced closer) at depth 0.
39
+ *
40
+ * @param {string} code
41
+ * @param {number} from Index right after the first declarator name.
42
+ * @param {Set<string>} names Collector — extra names are added in place.
43
+ * @returns {void}
44
+ */
45
+ function collectExtraDeclarators(code, from, names) {
46
+ let depth = 0;
47
+ let i = from;
48
+ const n = code.length;
49
+ while (i < n) {
50
+ const ch = code[i];
51
+ if (ch === '"' || ch === "'" || ch === "`") {
52
+ i++;
53
+ while (i < n && code[i] !== ch) {
54
+ if (code[i] === "\\") i++;
55
+ i++;
56
+ }
57
+ } else if (ch === "(" || ch === "[" || ch === "{") {
58
+ depth++;
59
+ } else if (ch === ")" || ch === "]" || ch === "}") {
60
+ if (depth === 0) return; // end of enclosing block / malformed
61
+ depth--;
62
+ } else if (ch === ";" && depth === 0) {
63
+ return;
64
+ } else if (ch === "," && depth === 0) {
65
+ const dm = code.slice(i + 1).match(/^\s*([A-Za-z_$][\w$]*)/);
66
+ if (dm) names.add(dm[1]);
67
+ }
68
+ i++;
69
+ }
70
+ }
71
+
21
72
  /**
22
73
  * Register fc-portal-component and fc-portal-utility config nodes.
23
74
  *
@@ -37,6 +88,15 @@ function registerRegistryNodes(RED, deps) {
37
88
  scheduleRebuildUsing,
38
89
  } = deps;
39
90
 
91
+ // Name each component/utility node currently registers: { nodeId: name }.
92
+ // Registry entries survive a plain redeploy (close(removed=false) keeps
93
+ // them so an unchanged deploy stays a no-op) — this map is what lets a
94
+ // RENAME on redeploy free the old entry instead of leaking it.
95
+ if (!RED.settings.portalReactNodeNames) {
96
+ RED.settings.portalReactNodeNames = {};
97
+ }
98
+ const nodeNames = RED.settings.portalReactNodeNames;
99
+
40
100
  function PortalComponentNode(config) {
41
101
  RED.nodes.createNode(this, config);
42
102
  const node = this;
@@ -48,6 +108,19 @@ function registerRegistryNodes(RED, deps) {
48
108
  return;
49
109
  }
50
110
 
111
+ // Renamed on redeploy → free the previous entry owned by this node.
112
+ const prevOwnedComp = nodeNames[node.id];
113
+ if (prevOwnedComp && prevOwnedComp !== compName) {
114
+ if (compNameOwners[prevOwnedComp] === node.id) {
115
+ delete compNameOwners[prevOwnedComp];
116
+ }
117
+ if (registry[prevOwnedComp]) {
118
+ delete registry[prevOwnedComp];
119
+ scheduleRebuildUsing(prevOwnedComp);
120
+ }
121
+ delete nodeNames[node.id];
122
+ }
123
+
51
124
  const existingOwner = compNameOwners[compName];
52
125
  if (existingOwner && existingOwner !== node.id) {
53
126
  node.error(
@@ -80,6 +153,7 @@ function registerRegistryNodes(RED, deps) {
80
153
  return;
81
154
  }
82
155
  compNameOwners[compName] = node.id;
156
+ nodeNames[node.id] = compName;
83
157
 
84
158
  const newCode = config.compCode || "";
85
159
  const prevCode = registry[compName]?.code;
@@ -100,12 +174,21 @@ function registerRegistryNodes(RED, deps) {
100
174
 
101
175
  if (prevCode !== newCode) scheduleRebuildUsing(compName);
102
176
 
103
- node.on("close", function (_removed, done) {
104
- if (compNameOwners[compName] === node.id) {
105
- delete compNameOwners[compName];
177
+ node.on("close", function (removed, done) {
178
+ // Plain redeploy (removed=false): keep the registry entry. The new
179
+ // instance re-registers in the same deploy pass and can compare
180
+ // prevCode — an unchanged component deploy stays a true no-op.
181
+ // Only delete/disable (removed=true) drops the entry.
182
+ if (removed) {
183
+ if (compNameOwners[compName] === node.id) {
184
+ delete compNameOwners[compName];
185
+ }
186
+ if (nodeNames[node.id] === compName) {
187
+ delete nodeNames[node.id];
188
+ }
189
+ delete registry[compName];
190
+ scheduleRebuildUsing(compName);
106
191
  }
107
- delete registry[compName];
108
- scheduleRebuildUsing(compName);
109
192
  done();
110
193
  });
111
194
  }
@@ -122,6 +205,24 @@ function registerRegistryNodes(RED, deps) {
122
205
  return;
123
206
  }
124
207
 
208
+ // Renamed on redeploy → free the previous entry + its symbol ownership.
209
+ const prevOwnedUtil = nodeNames[node.id];
210
+ if (prevOwnedUtil && prevOwnedUtil !== utilName) {
211
+ if (compNameOwners[prevOwnedUtil] === node.id) {
212
+ delete compNameOwners[prevOwnedUtil];
213
+ }
214
+ const oldSyms = extractUtilitySymbols(utilities[prevOwnedUtil]?.code || "");
215
+ for (const s of oldSyms) {
216
+ if (utilSymbolOwners[s] === prevOwnedUtil) delete utilSymbolOwners[s];
217
+ }
218
+ if (utilities[prevOwnedUtil]) {
219
+ delete utilities[prevOwnedUtil];
220
+ scheduleRebuildUsing(prevOwnedUtil);
221
+ for (const s of oldSyms) scheduleRebuildUsing(s);
222
+ }
223
+ delete nodeNames[node.id];
224
+ }
225
+
125
226
  const existingOwner = compNameOwners[utilName];
126
227
  if (existingOwner && existingOwner !== node.id) {
127
228
  node.error(
@@ -138,6 +239,7 @@ function registerRegistryNodes(RED, deps) {
138
239
  return;
139
240
  }
140
241
  compNameOwners[utilName] = node.id;
242
+ nodeNames[node.id] = utilName;
141
243
 
142
244
  const newCode = config.utilCode || "";
143
245
  const prevCode = utilities[utilName]?.code;
@@ -202,17 +304,24 @@ function registerRegistryNodes(RED, deps) {
202
304
  for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
203
305
  }
204
306
 
205
- node.on("close", function (_removed, done) {
206
- if (compNameOwners[utilName] === node.id) {
207
- delete compNameOwners[utilName];
208
- }
209
- for (const s of Object.keys(utilSymbolOwners)) {
210
- if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
307
+ node.on("close", function (removed, done) {
308
+ // Plain redeploy (removed=false): keep the utility entry + symbol
309
+ // ownership so an unchanged deploy stays a no-op (see component close).
310
+ if (removed) {
311
+ if (compNameOwners[utilName] === node.id) {
312
+ delete compNameOwners[utilName];
313
+ }
314
+ if (nodeNames[node.id] === utilName) {
315
+ delete nodeNames[node.id];
316
+ }
317
+ for (const s of Object.keys(utilSymbolOwners)) {
318
+ if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
319
+ }
320
+ const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
321
+ delete utilities[utilName];
322
+ scheduleRebuildUsing(utilName);
323
+ for (const s of removedSyms) scheduleRebuildUsing(s);
211
324
  }
212
- const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
213
- delete utilities[utilName];
214
- scheduleRebuildUsing(utilName);
215
- for (const s of removedSyms) scheduleRebuildUsing(s);
216
325
  done();
217
326
  });
218
327
  }
@@ -95,6 +95,9 @@
95
95
  */
96
96
 
97
97
  const crypto = require("crypto");
98
+ const packageInfo = require("../package.json");
99
+
100
+ const CACHE_SCHEMA_VERSION = "portal-react-cache-v2";
98
101
 
99
102
  module.exports = function (RED) {
100
103
  // ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
@@ -287,6 +290,16 @@ module.exports = function (RED) {
287
290
  }
288
291
  const endpointOwners = RED.settings.portalReactEndpointOwners;
289
292
 
293
+ // Track the endpoint each portal node currently serves: { nodeId: endpoint }.
294
+ // A sub-path change arrives as a plain redeploy (close(removed=false)), which
295
+ // intentionally keeps routes and pageState alive — without this map the old
296
+ // URL would keep serving the holding page forever. The constructor compares
297
+ // against this map and tears down the previous endpoint on mismatch.
298
+ if (!RED.settings.portalReactNodeEndpoints) {
299
+ RED.settings.portalReactNodeEndpoints = {};
300
+ }
301
+ const nodeEndpoints = RED.settings.portalReactNodeEndpoints;
302
+
290
303
  // Track component name ownership: { compName: nodeId } — prevents duplicate component names
291
304
  // Shared namespace with fc-portal-utility nodes so a component and a utility
292
305
  // can never share the same name.
@@ -437,7 +450,9 @@ module.exports = function (RED) {
437
450
  if (
438
451
  (usedComps && usedComps.has(name)) ||
439
452
  (usedUtils && usedUtils.has(name)) ||
440
- raw.includes(name)
453
+ // Identifier-boundary match, not substring — a dirty `Button`
454
+ // must not rebuild portals that only use `ButtonGroup`.
455
+ identifierRe(name).test(raw)
441
456
  ) {
442
457
  targetIds.add(nodeId);
443
458
  break;
@@ -449,14 +464,15 @@ module.exports = function (RED) {
449
464
  const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
450
465
  let i = 0;
451
466
  /**
452
- * Trampoline over the rebuild list — yields to the event loop between
467
+ * Trampoline over the rebuild list — awaits each (async) rebuild so
468
+ * builds run sequentially, and yields to the event loop between
453
469
  * iterations so a heavy queue does not block the HTTP server.
454
- * @returns {void}
470
+ * @returns {Promise<void>}
455
471
  * @private
456
472
  */
457
- function next() {
473
+ async function next() {
458
474
  if (i >= fns.length) return;
459
- try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
475
+ try { await fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
460
476
  i++;
461
477
  if (i < fns.length) setImmediate(next);
462
478
  }
@@ -477,6 +493,7 @@ module.exports = function (RED) {
477
493
  isSafeName,
478
494
  validateSubPath,
479
495
  findMissingComponentRefs,
496
+ identifierRe,
480
497
  userDir,
481
498
  readCachedJS,
482
499
  writeCachedJS,
@@ -594,6 +611,25 @@ module.exports = function (RED) {
594
611
  }
595
612
  endpointOwners[endpoint] = nodeId;
596
613
 
614
+ // ── Sub-path changed on redeploy → tear down the previous endpoint ──
615
+ // Route, pageState, cache and recovery entries of the old URL must go,
616
+ // or it keeps serving the "Building…" holding page until restart.
617
+ const prevEndpoint = nodeEndpoints[nodeId];
618
+ if (prevEndpoint && prevEndpoint !== endpoint) {
619
+ const oldSt = pageState[prevEndpoint];
620
+ if (oldSt?.jsxHash && !isHashInUse(oldSt.jsxHash, pageState, prevEndpoint)) {
621
+ deleteCacheFiles(oldSt.jsxHash);
622
+ }
623
+ delete pageState[prevEndpoint];
624
+ removeRoute(RED.httpNode._router, prevEndpoint);
625
+ delete registeredRoutes[prevEndpoint];
626
+ if (endpointOwners[prevEndpoint] === nodeId) {
627
+ delete endpointOwners[prevEndpoint];
628
+ }
629
+ lastBroadcastCache.delete(prevEndpoint);
630
+ }
631
+ nodeEndpoints[nodeId] = endpoint;
632
+
597
633
  // State
598
634
  const clients = new Map(); // portalClient → ws
599
635
  const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
@@ -696,11 +732,11 @@ module.exports = function (RED) {
696
732
  * Errors set `pageState[endpoint].compiled.error` and the route handler
697
733
  * serves an error page (or the previous lastGood build with a banner).
698
734
  *
699
- * @returns {void}
735
+ * @returns {Promise<void>}
700
736
  * @throws never — internal exceptions are caught and stored in pageState.
701
737
  * @private
702
738
  */
703
- function rebuild() {
739
+ async function rebuild() {
704
740
  try {
705
741
  // ── Pre-build: clear cache, set building state, notify browsers ──
706
742
  const prevState = pageState[endpoint];
@@ -718,27 +754,11 @@ module.exports = function (RED) {
718
754
  const allEntries = Object.entries(registry);
719
755
  const needed = new Set();
720
756
 
721
- // Word-boundary identifier match. Avoids prefix collisions (e.g. a
722
- // `Button` rebuild marking `ButtonGroup` users as needing rebuild)
723
- // and matches identifiers, not substrings inside other words.
724
- // Regexes are cached per name so we don't pay the constructor cost
725
- // on every recursion of addWithDeps.
726
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
727
- const nameReCache = new Map();
728
- const refRe = (n) => {
729
- let r = nameReCache.get(n);
730
- if (!r) {
731
- r = new RegExp(`\\b${escapeRe(n)}\\b`);
732
- nameReCache.set(n, r);
733
- }
734
- return r;
735
- };
736
-
737
757
  /**
738
758
  * Depth-first walk that pulls a component and every transitively
739
- * referenced sibling into `needed`. Matching uses `\b<name>\b`
740
- * (cached) so a component named `Button` does not pull in
741
- * `ButtonGroup`.
759
+ * referenced sibling into `needed`. Matching uses `identifierRe`
760
+ * (cached, identifier-boundary) so a component named `Button` does
761
+ * not pull in `ButtonGroup`.
742
762
  * @param {string} name
743
763
  * @returns {void}
744
764
  * @private
@@ -749,14 +769,14 @@ module.exports = function (RED) {
749
769
  if (!entry) return;
750
770
  needed.add(name);
751
771
  for (const [other] of allEntries) {
752
- if (other !== name && refRe(other).test(entry.code)) {
772
+ if (other !== name && identifierRe(other).test(entry.code)) {
753
773
  addWithDeps(other);
754
774
  }
755
775
  }
756
776
  }
757
777
 
758
778
  for (const [name] of allEntries) {
759
- if (refRe(name).test(componentCode)) {
779
+ if (identifierRe(name).test(componentCode)) {
760
780
  addWithDeps(name);
761
781
  }
762
782
  }
@@ -777,10 +797,11 @@ module.exports = function (RED) {
777
797
  const includedLibraryCode = [...needed]
778
798
  .map((n) => registry[n]?.code || "")
779
799
  .join("\n");
800
+ // identifierRe escapes regex metacharacters and handles `$`-edged
801
+ // names — symbols come from user code, so both matter here.
780
802
  const referencesAnySymbol = (text, syms) => {
781
803
  for (const s of syms) {
782
- const re = new RegExp(`\\b${s}\\b`);
783
- if (re.test(text)) return true;
804
+ if (identifierRe(s).test(text)) return true;
784
805
  }
785
806
  return false;
786
807
  };
@@ -812,19 +833,44 @@ module.exports = function (RED) {
812
833
  }
813
834
  portalNeededUtilities[nodeId] = new Set(neededUtils);
814
835
 
815
- // Topological sort only needed components
816
- const entries = allEntries.filter(([n]) => needed.has(n));
817
- entries.sort((a, b) => {
818
- const aUsesB = a[1].code.includes(b[0]);
819
- const bUsesA = b[1].code.includes(a[0]);
820
- if (aUsesB && !bUsesA) return 1; // a depends on b → b first
821
- if (bUsesA && !aUsesB) return -1; // b depends on a → a first
822
- return 0;
823
- });
824
- const libraryJsx = entries
836
+ // Topological sort of needed components (Kahn). Edge b→a when a's
837
+ // code references b, i.e. b must be emitted first. A pairwise
838
+ // Array.sort comparator is NOT transitive over dependency chains
839
+ // (A→B→C without a direct A→C reference), so a real topo sort is
840
+ // required. Cycles (mutual references) fall back to registry
841
+ // insertion order for the remainder.
842
+ const neededNames = allEntries
843
+ .filter(([n]) => needed.has(n))
844
+ .map(([n]) => n);
845
+ const indegree = new Map(neededNames.map((n) => [n, 0]));
846
+ const dependents = new Map(neededNames.map((n) => [n, []]));
847
+ for (const a of neededNames) {
848
+ const codeA = registry[a].code;
849
+ for (const b of neededNames) {
850
+ if (a !== b && identifierRe(b).test(codeA)) {
851
+ dependents.get(b).push(a);
852
+ indegree.set(a, indegree.get(a) + 1);
853
+ }
854
+ }
855
+ }
856
+ const topoQueue = neededNames.filter((n) => indegree.get(n) === 0);
857
+ const ordered = [];
858
+ while (topoQueue.length > 0) {
859
+ const n = topoQueue.shift();
860
+ ordered.push(n);
861
+ for (const d of dependents.get(n)) {
862
+ indegree.set(d, indegree.get(d) - 1);
863
+ if (indegree.get(d) === 0) topoQueue.push(d);
864
+ }
865
+ }
866
+ if (ordered.length < neededNames.length) {
867
+ const emitted = new Set(ordered);
868
+ for (const n of neededNames) if (!emitted.has(n)) ordered.push(n);
869
+ }
870
+ const libraryJsx = ordered
825
871
  .map(
826
- ([name, c]) =>
827
- `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
872
+ (name) =>
873
+ `// Library: ${name}\nconst ${name} = (() => {\n${registry[name].code}\nreturn ${name};\n})();`,
828
874
  )
829
875
  .join("\n\n");
830
876
 
@@ -943,7 +989,9 @@ module.exports = function (RED) {
943
989
  "createRoot(document.getElementById('root')).render(React.createElement(App));",
944
990
  ].join("\n");
945
991
 
946
- const jsxHash = hash(fullJsx);
992
+ const jsxHash = hash(
993
+ [CACHE_SCHEMA_VERSION, packageInfo.version, fullJsx].join("\0"),
994
+ );
947
995
 
948
996
  // ── Check: any used component or utility has its own syntax error ──
949
997
  let errorSource = null;
@@ -1036,7 +1084,12 @@ module.exports = function (RED) {
1036
1084
  compiled = readCachedJS(jsxHash);
1037
1085
  cacheHit = !!compiled;
1038
1086
  if (!compiled) {
1039
- compiled = transpile(fullJsx);
1087
+ compiled = await transpile(fullJsx);
1088
+ // The await opens a window where close() may have torn this node
1089
+ // down (redeploy/removal). Writing pageState now would resurrect
1090
+ // state for a dead node — bail out; the successor node's own
1091
+ // rebuild owns the endpoint from here.
1092
+ if (isClosing) return;
1040
1093
  if (!compiled.error) {
1041
1094
  writeCachedJS(jsxHash, compiled.js, compiled.metafile);
1042
1095
  }
@@ -1097,9 +1150,13 @@ module.exports = function (RED) {
1097
1150
  cssHash: prevState.cssHash,
1098
1151
  });
1099
1152
  }
1100
- return generateCSS(fullJsx).then(({ css, cssHash }) => {
1153
+ // cssHash is always jsxHash generateCSS hashes raw fullJsx
1154
+ // (no schema-version prefix), which would give the same CSS a
1155
+ // different URL on cache-hit vs cache-miss builds and force a
1156
+ // pointless browser refetch after redeploy.
1157
+ return generateCSS(fullJsx).then(({ css }) => {
1101
1158
  writeCachedCSS(jsxHash, css);
1102
- return { css, cssHash };
1159
+ return { css, cssHash: jsxHash };
1103
1160
  });
1104
1161
  })().catch((err) => {
1105
1162
  // CSS generation failed (Tailwind compile error, missing
@@ -1118,10 +1175,19 @@ module.exports = function (RED) {
1118
1175
  lastJsxHash = jsxHash;
1119
1176
 
1120
1177
  // Preserve last successful build so that on transpile errors we keep
1121
- // serving the previous working JS instead of throwing clients to an error page.
1178
+ // serving the previous working JS instead of throwing clients to an
1179
+ // error page. On success, snapshot IMMEDIATELY (not after cssReady
1180
+ // resolves) — a rebuild failing inside that async window must still
1181
+ // find a fallback. cssHash is patched in when cssReady settles.
1122
1182
  const lastGood = compiled.error
1123
1183
  ? prevState?.lastGood || null
1124
- : null; // will be populated after cssReady resolves on success
1184
+ : {
1185
+ compiledJs: compiled.js,
1186
+ contentHash,
1187
+ cssHash: "",
1188
+ pageTitle,
1189
+ customHead,
1190
+ };
1125
1191
 
1126
1192
  pageState[endpoint] = {
1127
1193
  compiled,
@@ -1490,7 +1556,9 @@ module.exports = function (RED) {
1490
1556
  }
1491
1557
  lastBroadcastCache.set(endpoint, cached);
1492
1558
  }
1493
- updateStatus();
1559
+ // No updateStatus() here — client count only changes on WS
1560
+ // connect/disconnect, and emitting a status event per routed msg
1561
+ // floods the editor comms channel on high-rate streams.
1494
1562
  done();
1495
1563
  } catch (err) {
1496
1564
  // Catch-node propagation: done(err) lets the runtime route the
@@ -1576,6 +1644,7 @@ module.exports = function (RED) {
1576
1644
  if (removed) {
1577
1645
  lastBroadcastCache.delete(endpoint);
1578
1646
  delete portalSig[nodeId];
1647
+ delete nodeEndpoints[nodeId];
1579
1648
  }
1580
1649
 
1581
1650
  // Clear the userIndex — WS clients are already closed above, but
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaqu/fromcubes-portal-react",
3
- "version": "0.1.0-alpha.26",
3
+ "version": "0.1.0-alpha.28",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "type": "commonjs",
6
6
  "keywords": [