@aaqu/fromcubes-portal-react 0.1.0-alpha.27 → 0.1.0-alpha.29

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
  *
@@ -401,6 +427,44 @@ function hasFreshBuild(state) {
401
427
  return !!state && !state.building && !!state.compiled && !state.compiled.error;
402
428
  }
403
429
 
430
+ /**
431
+ * Decide whether the `preInstall` hook may veto an npm install because the
432
+ * module is already on disk. Vetoing is ONLY safe for plain installs of an
433
+ * already-present package (the offline/Docker case). It must never swallow:
434
+ *
435
+ * - **upgrades** (`event.isUpgrade`) — the palette manager's "update"
436
+ * button routes through the same install path; vetoing it makes Node-RED
437
+ * mark the module `pendingUpdated` and demand a restart while the old
438
+ * files stay on disk — the update silently never lands,
439
+ * - **explicit version requests** that differ from the installed version
440
+ * (e.g. a `libs` entry bumped from `^4.4.0`) — npm itself no-ops when
441
+ * the range is already satisfied, so letting it run is the safe side.
442
+ *
443
+ * An unreadable/corrupt package.json also proceeds with the install (npm
444
+ * repairs what we cannot verify).
445
+ *
446
+ * @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
447
+ * `preInstall` hook payload from `@node-red/registry`.
448
+ * @returns {boolean} true → hook should return false (skip npm install).
449
+ */
450
+ function shouldSkipInstall(event) {
451
+ if (event.isUpgrade) return false;
452
+ const modDir = path.join(event.dir, "node_modules", event.module);
453
+ if (!fs.existsSync(modDir)) return false;
454
+ if (event.version) {
455
+ let installed;
456
+ try {
457
+ installed = JSON.parse(
458
+ fs.readFileSync(path.join(modDir, "package.json"), "utf8"),
459
+ ).version;
460
+ } catch (_) {
461
+ return false;
462
+ }
463
+ if (installed !== event.version) return false;
464
+ }
465
+ return true;
466
+ }
467
+
404
468
  module.exports = function (RED) {
405
469
  return createHelpers(RED);
406
470
  };
@@ -411,8 +475,10 @@ module.exports.quickCheckSyntax = quickCheckSyntax;
411
475
  module.exports.formatEsbuildError = formatEsbuildError;
412
476
  module.exports.extractPortalUser = extractPortalUser;
413
477
  module.exports.findMissingComponentRefs = findMissingComponentRefs;
478
+ module.exports.identifierRe = identifierRe;
414
479
  module.exports.serveableHash = serveableHash;
415
480
  module.exports.hasFreshBuild = hasFreshBuild;
481
+ module.exports.shouldSkipInstall = shouldSkipInstall;
416
482
  module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
417
483
  module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
418
484
 
@@ -434,20 +500,22 @@ function createHelpers(RED) {
434
500
  * `preInstall` hook (Node-RED 1.3+) — vetoes an npm install when the
435
501
  * package is already on disk. Useful for offline/Docker setups where
436
502
  * Node-RED's auto-install pass would otherwise try to hit the registry
437
- * and fail. Hook itself is *optional by design*: any unexpected error
503
+ * and fail. Upgrades and mismatched explicit versions are never vetoed
504
+ * (see {@link shouldSkipInstall}) — vetoing an upgrade leaves the old
505
+ * files on disk while Node-RED demands a restart, so the update never
506
+ * installs. Hook itself is *optional by design*: any unexpected error
438
507
  * inside the body MUST NOT bubble up (it would cancel an install path
439
508
  * the user actually expected to run). We log at `trace` level so the
440
509
  * developer can opt into diagnostics via `logging.level = trace` in
441
510
  * `settings.js`, without spamming default-level logs.
442
511
  *
443
- * @param {{dir: string, module: string}} event
512
+ * @param {{dir: string, module: string, version?: string, isUpgrade?: boolean}} event
444
513
  * @returns {boolean|void} `false` skips the install; anything else proceeds.
445
514
  * @listens RED.hooks#preInstall.portalReact
446
515
  */
447
516
  RED.hooks.add("preInstall.portalReact", (event) => {
448
517
  try {
449
- const modDir = path.join(event.dir, "node_modules", event.module);
450
- if (fs.existsSync(modDir)) {
518
+ if (shouldSkipInstall(event)) {
451
519
  RED.log.info(
452
520
  `[portal-react] ${event.module} already in node_modules, skipping install`,
453
521
  );
@@ -575,21 +643,24 @@ function createHelpers(RED) {
575
643
 
576
644
  /**
577
645
  * 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.
646
+ * into a minified IIFE. Pre-validates with `quickCheckSyntax` first so
647
+ * malformed input never reaches the bundler. Uses the async `esbuild.build`
648
+ * API a large bundle (three.js et al.) runs in esbuild's service process
649
+ * without blocking the Node-RED event loop. The `react`/`react-dom` alias
650
+ * points at this package's own copies so peer-dep packages share a single
651
+ * React instance.
582
652
  *
583
653
  * @param {string} jsx
584
- * @returns {TranspileResult}
654
+ * @returns {Promise<TranspileResult>}
585
655
  */
586
- function transpile(jsx) {
587
- // Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
656
+ async function transpile(jsx) {
657
+ // Pre-validate with transformSync (fast, no bundling) so syntax errors get
658
+ // clean line-numbered diagnostics before any resolution work
588
659
  const syntaxErr = quickCheckSyntax(jsx);
589
660
  if (syntaxErr) return { js: null, error: syntaxErr };
590
661
  // Syntax OK — bundle with full resolution
591
662
  try {
592
- const buildResult = esbuild.buildSync({
663
+ const buildResult = await esbuild.build({
593
664
  stdin: {
594
665
  contents: jsx,
595
666
  resolveDir: pkgRoot,
@@ -639,6 +710,7 @@ function createHelpers(RED) {
639
710
  isSafeName,
640
711
  validateSubPath,
641
712
  findMissingComponentRefs,
713
+ identifierRe,
642
714
  pkgRoot,
643
715
  userDir,
644
716
  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
  }
@@ -290,6 +290,16 @@ module.exports = function (RED) {
290
290
  }
291
291
  const endpointOwners = RED.settings.portalReactEndpointOwners;
292
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
+
293
303
  // Track component name ownership: { compName: nodeId } — prevents duplicate component names
294
304
  // Shared namespace with fc-portal-utility nodes so a component and a utility
295
305
  // can never share the same name.
@@ -440,7 +450,9 @@ module.exports = function (RED) {
440
450
  if (
441
451
  (usedComps && usedComps.has(name)) ||
442
452
  (usedUtils && usedUtils.has(name)) ||
443
- 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)
444
456
  ) {
445
457
  targetIds.add(nodeId);
446
458
  break;
@@ -452,14 +464,15 @@ module.exports = function (RED) {
452
464
  const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
453
465
  let i = 0;
454
466
  /**
455
- * 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
456
469
  * iterations so a heavy queue does not block the HTTP server.
457
- * @returns {void}
470
+ * @returns {Promise<void>}
458
471
  * @private
459
472
  */
460
- function next() {
473
+ async function next() {
461
474
  if (i >= fns.length) return;
462
- 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); }
463
476
  i++;
464
477
  if (i < fns.length) setImmediate(next);
465
478
  }
@@ -480,6 +493,7 @@ module.exports = function (RED) {
480
493
  isSafeName,
481
494
  validateSubPath,
482
495
  findMissingComponentRefs,
496
+ identifierRe,
483
497
  userDir,
484
498
  readCachedJS,
485
499
  writeCachedJS,
@@ -597,6 +611,25 @@ module.exports = function (RED) {
597
611
  }
598
612
  endpointOwners[endpoint] = nodeId;
599
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
+
600
633
  // State
601
634
  const clients = new Map(); // portalClient → ws
602
635
  const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
@@ -699,11 +732,11 @@ module.exports = function (RED) {
699
732
  * Errors set `pageState[endpoint].compiled.error` and the route handler
700
733
  * serves an error page (or the previous lastGood build with a banner).
701
734
  *
702
- * @returns {void}
735
+ * @returns {Promise<void>}
703
736
  * @throws never — internal exceptions are caught and stored in pageState.
704
737
  * @private
705
738
  */
706
- function rebuild() {
739
+ async function rebuild() {
707
740
  try {
708
741
  // ── Pre-build: clear cache, set building state, notify browsers ──
709
742
  const prevState = pageState[endpoint];
@@ -721,27 +754,11 @@ module.exports = function (RED) {
721
754
  const allEntries = Object.entries(registry);
722
755
  const needed = new Set();
723
756
 
724
- // Word-boundary identifier match. Avoids prefix collisions (e.g. a
725
- // `Button` rebuild marking `ButtonGroup` users as needing rebuild)
726
- // and matches identifiers, not substrings inside other words.
727
- // Regexes are cached per name so we don't pay the constructor cost
728
- // on every recursion of addWithDeps.
729
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
730
- const nameReCache = new Map();
731
- const refRe = (n) => {
732
- let r = nameReCache.get(n);
733
- if (!r) {
734
- r = new RegExp(`\\b${escapeRe(n)}\\b`);
735
- nameReCache.set(n, r);
736
- }
737
- return r;
738
- };
739
-
740
757
  /**
741
758
  * Depth-first walk that pulls a component and every transitively
742
- * referenced sibling into `needed`. Matching uses `\b<name>\b`
743
- * (cached) so a component named `Button` does not pull in
744
- * `ButtonGroup`.
759
+ * referenced sibling into `needed`. Matching uses `identifierRe`
760
+ * (cached, identifier-boundary) so a component named `Button` does
761
+ * not pull in `ButtonGroup`.
745
762
  * @param {string} name
746
763
  * @returns {void}
747
764
  * @private
@@ -752,14 +769,14 @@ module.exports = function (RED) {
752
769
  if (!entry) return;
753
770
  needed.add(name);
754
771
  for (const [other] of allEntries) {
755
- if (other !== name && refRe(other).test(entry.code)) {
772
+ if (other !== name && identifierRe(other).test(entry.code)) {
756
773
  addWithDeps(other);
757
774
  }
758
775
  }
759
776
  }
760
777
 
761
778
  for (const [name] of allEntries) {
762
- if (refRe(name).test(componentCode)) {
779
+ if (identifierRe(name).test(componentCode)) {
763
780
  addWithDeps(name);
764
781
  }
765
782
  }
@@ -780,10 +797,11 @@ module.exports = function (RED) {
780
797
  const includedLibraryCode = [...needed]
781
798
  .map((n) => registry[n]?.code || "")
782
799
  .join("\n");
800
+ // identifierRe escapes regex metacharacters and handles `$`-edged
801
+ // names — symbols come from user code, so both matter here.
783
802
  const referencesAnySymbol = (text, syms) => {
784
803
  for (const s of syms) {
785
- const re = new RegExp(`\\b${s}\\b`);
786
- if (re.test(text)) return true;
804
+ if (identifierRe(s).test(text)) return true;
787
805
  }
788
806
  return false;
789
807
  };
@@ -815,19 +833,44 @@ module.exports = function (RED) {
815
833
  }
816
834
  portalNeededUtilities[nodeId] = new Set(neededUtils);
817
835
 
818
- // Topological sort only needed components
819
- const entries = allEntries.filter(([n]) => needed.has(n));
820
- entries.sort((a, b) => {
821
- const aUsesB = a[1].code.includes(b[0]);
822
- const bUsesA = b[1].code.includes(a[0]);
823
- if (aUsesB && !bUsesA) return 1; // a depends on b → b first
824
- if (bUsesA && !aUsesB) return -1; // b depends on a → a first
825
- return 0;
826
- });
827
- 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
828
871
  .map(
829
- ([name, c]) =>
830
- `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
872
+ (name) =>
873
+ `// Library: ${name}\nconst ${name} = (() => {\n${registry[name].code}\nreturn ${name};\n})();`,
831
874
  )
832
875
  .join("\n\n");
833
876
 
@@ -1041,7 +1084,12 @@ module.exports = function (RED) {
1041
1084
  compiled = readCachedJS(jsxHash);
1042
1085
  cacheHit = !!compiled;
1043
1086
  if (!compiled) {
1044
- 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;
1045
1093
  if (!compiled.error) {
1046
1094
  writeCachedJS(jsxHash, compiled.js, compiled.metafile);
1047
1095
  }
@@ -1102,9 +1150,13 @@ module.exports = function (RED) {
1102
1150
  cssHash: prevState.cssHash,
1103
1151
  });
1104
1152
  }
1105
- 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 }) => {
1106
1158
  writeCachedCSS(jsxHash, css);
1107
- return { css, cssHash };
1159
+ return { css, cssHash: jsxHash };
1108
1160
  });
1109
1161
  })().catch((err) => {
1110
1162
  // CSS generation failed (Tailwind compile error, missing
@@ -1123,10 +1175,19 @@ module.exports = function (RED) {
1123
1175
  lastJsxHash = jsxHash;
1124
1176
 
1125
1177
  // Preserve last successful build so that on transpile errors we keep
1126
- // 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.
1127
1182
  const lastGood = compiled.error
1128
1183
  ? prevState?.lastGood || null
1129
- : null; // will be populated after cssReady resolves on success
1184
+ : {
1185
+ compiledJs: compiled.js,
1186
+ contentHash,
1187
+ cssHash: "",
1188
+ pageTitle,
1189
+ customHead,
1190
+ };
1130
1191
 
1131
1192
  pageState[endpoint] = {
1132
1193
  compiled,
@@ -1495,7 +1556,9 @@ module.exports = function (RED) {
1495
1556
  }
1496
1557
  lastBroadcastCache.set(endpoint, cached);
1497
1558
  }
1498
- 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.
1499
1562
  done();
1500
1563
  } catch (err) {
1501
1564
  // Catch-node propagation: done(err) lets the runtime route the
@@ -1581,6 +1644,7 @@ module.exports = function (RED) {
1581
1644
  if (removed) {
1582
1645
  lastBroadcastCache.delete(endpoint);
1583
1646
  delete portalSig[nodeId];
1647
+ delete nodeEndpoints[nodeId];
1584
1648
  }
1585
1649
 
1586
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.27",
3
+ "version": "0.1.0-alpha.29",
4
4
  "description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
5
5
  "type": "commonjs",
6
6
  "keywords": [