@stacksjs/stx 0.2.110 → 0.2.112

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.
@@ -199,68 +199,74 @@ function isBalancedForTag(inner, tag) {
199
199
  return !0;
200
200
  }
201
201
  export function convertSignalDirectivesToAttributes(template, context) {
202
- let output = template;
203
- const signalNames = extractClientSignalNames(template), ifDirectiveStart = /@if\s*\(/g;
204
- let match;
205
- const replacements = [];
206
- while ((match = ifDirectiveStart.exec(output)) !== null) {
207
- const startIdx = match.index, exprStart = startIdx + match[0].length;
208
- let depth = 1, i = exprStart;
209
- while (i < output.length && depth > 0) {
210
- if (output[i] === "(")
211
- depth++;
212
- else if (output[i] === ")")
213
- depth--;
214
- i++;
215
- }
216
- if (depth !== 0)
217
- continue;
218
- const condition = output.substring(exprStart, i - 1).trim(), afterCondition = i;
219
- let ifDepth = 1, endIdx = afterCondition;
220
- const endifRegex = /@(if\s*\(|endif)/g;
221
- endifRegex.lastIndex = afterCondition;
222
- let endMatch;
223
- while ((endMatch = endifRegex.exec(output)) !== null)
224
- if (endMatch[1].startsWith("if"))
225
- ifDepth++;
226
- else if (endMatch[1] === "endif") {
227
- ifDepth--;
228
- if (ifDepth === 0) {
229
- endIdx = endMatch.index + endMatch[0].length;
230
- break;
231
- }
202
+ const signalNames = extractClientSignalNames(template);
203
+ function convert(input) {
204
+ let output = input;
205
+ const ifDirectiveStart = /@if\s*\(/g;
206
+ let match;
207
+ const replacements = [];
208
+ while ((match = ifDirectiveStart.exec(output)) !== null) {
209
+ const startIdx = match.index, exprStart = startIdx + match[0].length;
210
+ let depth = 1, i = exprStart;
211
+ while (i < output.length && depth > 0) {
212
+ if (output[i] === "(")
213
+ depth++;
214
+ else if (output[i] === ")")
215
+ depth--;
216
+ i++;
232
217
  }
233
- if (ifDepth !== 0)
234
- continue;
235
- const content = output.substring(afterCondition, endIdx - 6).trim();
236
- let replacement;
237
- if (hasTopLevelElseBranch(content)) {
238
- const parsed = findIfBlocks(output.substring(startIdx, endIdx));
239
- if (parsed.length === 0)
218
+ if (depth !== 0)
240
219
  continue;
241
- const valueBranches = parsed[0].branches.filter((b) => b.type !== "else");
242
- if (!(valueBranches.length > 0 && valueBranches.every((b) => conditionIsClientReactive(b.condition, signalNames, context))))
220
+ const condition = output.substring(exprStart, i - 1).trim(), afterCondition = i;
221
+ let ifDepth = 1, endIdx = afterCondition;
222
+ const endifRegex = /@(if\s*\(|endif)/g;
223
+ endifRegex.lastIndex = afterCondition;
224
+ let endMatch;
225
+ while ((endMatch = endifRegex.exec(output)) !== null)
226
+ if (endMatch[1].startsWith("if"))
227
+ ifDepth++;
228
+ else if (endMatch[1] === "endif") {
229
+ ifDepth--;
230
+ if (ifDepth === 0) {
231
+ endIdx = endMatch.index + endMatch[0].length;
232
+ break;
233
+ }
234
+ }
235
+ if (ifDepth !== 0)
243
236
  continue;
244
- replacement = parsed[0].branches.map((branch) => {
245
- if (branch.type === "elseif")
246
- return branchToAttrElement(branch.content, "@else-if", branch.condition ?? "");
247
- if (branch.type === "else")
248
- return branchToAttrElement(branch.content, "@else");
249
- return branchToAttrElement(branch.content, "@if", branch.condition ?? "");
250
- }).join(`
237
+ const content = output.substring(afterCondition, endIdx - 6).trim();
238
+ let replacement;
239
+ if (hasTopLevelElseBranch(content)) {
240
+ const parsed = findIfBlocks(output.substring(startIdx, endIdx));
241
+ if (parsed.length === 0)
242
+ continue;
243
+ const valueBranches = parsed[0].branches.filter((b) => b.type !== "else");
244
+ if (!(valueBranches.length > 0 && valueBranches.every((b) => conditionIsClientReactive(b.condition, signalNames, context))))
245
+ continue;
246
+ replacement = parsed[0].branches.map((branch) => {
247
+ const branchContent = convert(branch.content);
248
+ if (branch.type === "elseif")
249
+ return branchToAttrElement(branchContent, "@else-if", branch.condition ?? "");
250
+ if (branch.type === "else")
251
+ return branchToAttrElement(branchContent, "@else");
252
+ return branchToAttrElement(branchContent, "@if", branch.condition ?? "");
253
+ }).join(`
251
254
  `);
252
- } else {
253
- if (!conditionIsClientReactive(condition, signalNames, context))
254
- continue;
255
- replacement = branchToAttrElement(content, "@if", condition);
255
+ } else {
256
+ if (!conditionIsClientReactive(condition, signalNames, context))
257
+ continue;
258
+ replacement = branchToAttrElement(convert(content), "@if", condition);
259
+ }
260
+ replacements.push({ start: startIdx, end: endIdx, replacement });
261
+ ifDirectiveStart.lastIndex = endIdx;
256
262
  }
257
- replacements.push({ start: startIdx, end: endIdx, replacement });
258
- }
259
- for (let i = replacements.length - 1;i >= 0; i--) {
260
- const { start, end, replacement } = replacements[i];
261
- output = output.substring(0, start) + replacement + output.substring(end);
263
+ for (let i = replacements.length - 1;i >= 0; i--) {
264
+ const { start, end, replacement } = replacements[i];
265
+ output = output.substring(0, start) + replacement + output.substring(end);
266
+ }
267
+ return output;
262
268
  }
263
- return output;
269
+ return convert(template);
264
270
  }
265
271
  function evalLiteralCondition(expr) {
266
272
  const trimmed = expr.trim();
package/dist/signals.js CHANGED
@@ -1579,7 +1579,7 @@ catch (e) {
1579
1579
  if (el.nodeType === Node.TEXT_NODE) {
1580
1580
  const text = el.textContent;
1581
1581
  if (text && text.includes('{{')) {
1582
- const parts = text.split(/(\\{\\{[^}]+\\}\\})/g);
1582
+ const parts = text.split(/(\\{\\{[\\s\\S]+?\\}\\})/g);
1583
1583
  if (parts.length > 1) {
1584
1584
  const fragment = document.createDocumentFragment();
1585
1585
  const parentEl = el.parentNode;
@@ -1588,7 +1588,7 @@ catch (e) {
1588
1588
  // through nested @if/@for processing where componentScope may be restored
1589
1589
  const capturedScope = { ...scope, ...(findElementScope(parentEl) || {}), ...globalHelpers };
1590
1590
  parts.forEach(part => {
1591
- const match = part.match(/^\\{\\{\\s*(.+?)\\s*\\}\\}$/);
1591
+ const match = part.match(/^\\{\\{\\s*([\\s\\S]+?)\\s*\\}\\}$/);
1592
1592
  if (match) {
1593
1593
  const expr = match[1];
1594
1594
  // Skip placeholder expressions like __TITLE__ (build-time placeholders)
@@ -2388,6 +2388,13 @@ else {
2388
2388
  }
2389
2389
 
2390
2390
  let currentElements = [];
2391
+ // One entry per list item, each holding EVERY node that item rendered.
2392
+ // currentElements stays flat for transition snapshots, but the keyed
2393
+ // diff below must pair a key with all of its nodes: a template row
2394
+ // routinely yields several (an element plus its surrounding text nodes),
2395
+ // and indexing a flat list by key position then paired the wrong node
2396
+ // with each key, leaving the list rotated after an update.
2397
+ let currentGroups = [];
2391
2398
  let currentKeys = [];
2392
2399
  let loadingElement = null;
2393
2400
  let emptyElement = null;
@@ -2529,6 +2536,7 @@ catch (e) {
2529
2536
  // Dispose scopes registered by items before they leave the DOM (#1727).
2530
2537
  currentElements.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
2531
2538
  currentElements = [];
2539
+ currentGroups = [];
2532
2540
  currentKeys = [];
2533
2541
  showLoading();
2534
2542
  return;
@@ -2577,6 +2585,7 @@ catch (e) {
2577
2585
  // Dispose scopes registered by items before they leave the DOM (#1727).
2578
2586
  currentElements.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
2579
2587
  currentElements = [];
2588
+ currentGroups = [];
2580
2589
  currentKeys = [];
2581
2590
  hideEmpty();
2582
2591
  return;
@@ -2617,6 +2626,7 @@ catch (e) {
2617
2626
  // Dispose scopes registered by items before they leave the DOM (#1727).
2618
2627
  currentElements.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
2619
2628
  currentElements = [];
2629
+ currentGroups = [];
2620
2630
  currentKeys = [];
2621
2631
  if (emptyExpr) {
2622
2632
  const emptyContent = evalLazy(emptyExpr);
@@ -2651,11 +2661,12 @@ catch (e) {
2651
2661
  for (let i = 0; i < currentKeys.length; i++) {
2652
2662
  const k = currentKeys[i];
2653
2663
  if (!oldKeyMap.has(k)) oldKeyMap.set(k, []);
2654
- oldKeyMap.get(k).push(currentElements[i]);
2664
+ oldKeyMap.get(k).push(currentGroups[i] || []);
2655
2665
  }
2656
2666
 
2657
2667
  // Build new element list, reusing existing DOM nodes by key
2658
2668
  const newElements = [];
2669
+ const newGroups = [];
2659
2670
  const usedKeys = new Set();
2660
2671
 
2661
2672
  for (let i = 0; i < list.length; i++) {
@@ -2663,10 +2674,12 @@ catch (e) {
2663
2674
  const existing = oldKeyMap.get(key);
2664
2675
 
2665
2676
  if (existing && existing.length > 0 && !usedKeys.has(key)) {
2666
- // Reuse existing element \u2014 move to correct position
2667
- const el = existing.shift();
2668
- parent.insertBefore(el, placeholder);
2669
- newElements.push(el);
2677
+ // Reuse this item's nodes \u2014 move the whole group into position, in
2678
+ // order, so a multi-node row stays contiguous and correctly ordered.
2679
+ const group = existing.shift();
2680
+ group.forEach(el => parent.insertBefore(el, placeholder));
2681
+ newElements.push(...group);
2682
+ newGroups.push(group);
2670
2683
  usedKeys.add(key);
2671
2684
  // Update the item signal so bindings re-evaluate with new data.
2672
2685
  // This is the fix for #1669 \u2014 without this, reused elements show
@@ -2681,14 +2694,16 @@ catch (e) {
2681
2694
  const elements = createItemElements(list[i], i, key);
2682
2695
  elements.forEach(el => { parent.insertBefore(el, placeholder); if (tgReady) tgEnter(el, tgName); });
2683
2696
  newElements.push(...elements);
2697
+ newGroups.push(elements);
2684
2698
  }
2685
2699
  }
2686
2700
 
2687
2701
  // Remove old elements whose keys are no longer in the list.
2688
2702
  // Dispose any scopes registered inside removed items before the
2689
2703
  // remove() call (#1727).
2690
- for (const [key, elements] of oldKeyMap) {
2704
+ for (const [key, groups] of oldKeyMap) {
2691
2705
  if (!usedKeys.has(key)) {
2706
+ const elements = groups.flat();
2692
2707
  // In a transition group, run the leave animation and let it remove the
2693
2708
  // node; otherwise remove immediately. Scopes are disposed up-front
2694
2709
  // either way (their destroy hooks shouldn't wait on a CSS transition).
@@ -2698,6 +2713,7 @@ catch (e) {
2698
2713
  }
2699
2714
 
2700
2715
  currentElements = newElements;
2716
+ currentGroups = newGroups;
2701
2717
  currentKeys = newKeys;
2702
2718
 
2703
2719
  // \u2500\u2500 <TransitionGroup> play (#1742) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -2775,6 +2791,18 @@ catch (e) {
2775
2791
  b.placeholder = document.createComment('stx-if-chain');
2776
2792
  parent.insertBefore(b.placeholder, b.el);
2777
2793
  b.el.removeAttribute(b.attr);
2794
+ // A <template> branch (the wrapper convertSignalDirectivesToAttributes
2795
+ // emits for a multi-child / text branch) is INERT \u2014 inserting the element
2796
+ // renders nothing, its content lives in a document fragment. Clone that
2797
+ // content ONCE into a stable node set we toggle in and out, exactly as
2798
+ // bindIf/bindFor already do for template elements. An element branch is
2799
+ // its own single node. Without this, a multi-element @else / @else-if
2800
+ // branch rendered blank the moment the markup was well-formed enough to
2801
+ // survive as a real <template> (the #1784 markup fix). See #1784.
2802
+ b.isTemplate = b.el.tagName === 'TEMPLATE';
2803
+ b.nodes = b.isTemplate
2804
+ ? Array.prototype.slice.call(b.el.content.cloneNode(true).childNodes)
2805
+ : [b.el];
2778
2806
  b.el.remove();
2779
2807
  b.childrenProcessed = false;
2780
2808
  });
@@ -2858,13 +2886,16 @@ catch (e2) {
2858
2886
  // components under reactive conditionals (#1737). Permanent disposal is
2859
2887
  // still handled by bindFor (item removal) and cleanupContainer (SPA nav).
2860
2888
  if (currentIdx !== -1) {
2861
- chain[currentIdx].el.remove();
2889
+ chain[currentIdx].nodes.forEach(function (n) { if (n.parentNode) n.parentNode.removeChild(n); });
2862
2890
  }
2863
2891
 
2864
2892
  // Insert + process the newly-picked branch.
2865
2893
  if (pickedIdx !== -1) {
2866
2894
  var pick = chain[pickedIdx];
2867
- pick.placeholder.parentNode.insertBefore(pick.el, pick.placeholder.nextSibling);
2895
+ // Insert this branch's node(s) before the placeholder's next sibling.
2896
+ // The anchor is captured once so the nodes stack in source order.
2897
+ var anchor = pick.placeholder.nextSibling;
2898
+ pick.nodes.forEach(function (n) { pick.placeholder.parentNode.insertBefore(n, anchor); });
2868
2899
  pick.el.__stx_shown_at = performance.now();
2869
2900
  if (!pick.childrenProcessed) {
2870
2901
  pick.childrenProcessed = true;
@@ -2874,9 +2905,12 @@ catch (e2) {
2874
2905
  (function (branch) {
2875
2906
  setTimeout(function () {
2876
2907
  var childScope = { ...capturedComponentScope, ...(branch.capturedElementScope || {}), ...globalHelpers };
2877
- processElement(branch.el, childScope);
2878
- branch.el.removeAttribute('x-cloak');
2879
- branch.el.querySelectorAll('[x-cloak]').forEach(function (c) { c.removeAttribute('x-cloak'); });
2908
+ branch.nodes.forEach(function (n) {
2909
+ if (n.nodeType !== 1) return; // whitespace/text between template children
2910
+ processElement(n, childScope);
2911
+ n.removeAttribute('x-cloak');
2912
+ n.querySelectorAll('[x-cloak]').forEach(function (c) { c.removeAttribute('x-cloak'); });
2913
+ });
2880
2914
  }, 0);
2881
2915
  })(pick);
2882
2916
  }
@@ -4847,6 +4881,10 @@ else {
4847
4881
  processedScopes.add(el);
4848
4882
 
4849
4883
  const scopeVars = window.stx._scopes && window.stx._scopes[scopeId];
4884
+ // Record the element this scope is bound to so cleanupContainer can tell a
4885
+ // dead registry entry (element replaced by an SPA swap) from a live one.
4886
+ // See sweepOrphanedScopes / #1775.
4887
+ if (scopeVars) scopeVars.__el = el;
4850
4888
 
4851
4889
  // Deferred island (#1746): its setup script (type="stx/island") hasn't run
4852
4890
  // yet, so the scope isn't registered. Arm the hydration trigger anyway \u2014
@@ -5006,6 +5044,54 @@ catch (e) {
5006
5044
  }
5007
5045
  }
5008
5046
 
5047
+ // Reclaim registry entries whose element is gone from the document.
5048
+ //
5049
+ // disposeSubtreeScopes only deletes scopes found UNDER the cleanup container,
5050
+ // so two shapes leaked forever across SPA navigation (#1775): components
5051
+ // rendered outside the swap container (an @include in the layout, above
5052
+ // <main>), and elements already detached by the time cleanup ran its
5053
+ // querySelectorAll. Each round-trip re-ran the include scripts under fresh
5054
+ // scope ids while the previous ids stayed in window.stx._scopes \u2014 ~4 orphans
5055
+ // per hop, unbounded, each retaining that scope's vars, signals and closures.
5056
+ //
5057
+ // The sweep is deliberately CONSERVATIVE \u2014 an over-eager delete is the #1737
5058
+ // failure (a scope removed from the registry cannot be recreated: its setup
5059
+ // IIFE ran once at page load), which is strictly worse than a leak. An entry
5060
+ // is dropped only when all three hold:
5061
+ // 1. the runtime actually bound it to an element (__el recorded),
5062
+ // 2. that element is no longer connected to the document, and
5063
+ // 3. neither it nor any ancestor is retained by a live conditional.
5064
+ // Entries with no __el (e.g. a scope whose element still sits inside untouched
5065
+ // <template> content, never inserted) are left alone \u2014 we can't prove they're
5066
+ // dead, and a later clone re-uses that registration.
5067
+ function sweepOrphanedScopes() {
5068
+ if (!window.stx || !window.stx._scopes) return;
5069
+ var scopes = window.stx._scopes;
5070
+ for (var id in scopes) {
5071
+ if (!Object.prototype.hasOwnProperty.call(scopes, id)) continue;
5072
+ var entry = scopes[id];
5073
+ if (!entry) continue;
5074
+ var el = entry.__el;
5075
+ if (!el || el.isConnected) continue;
5076
+ // Detached but RETAINED: :if toggles keep their element (bindIf re-inserts
5077
+ // the SAME node) and :if/:else chains keep their branches. Parent links
5078
+ // inside a detached subtree stay intact, so walking up from the scope
5079
+ // element still finds the conditional that owns it.
5080
+ var retained = false;
5081
+ for (var p = el; p; p = p.parentNode) {
5082
+ if (p.__stx_if_bound || p.__stx_chain_member) { retained = true; break; }
5083
+ }
5084
+ if (retained) continue;
5085
+ if (entry.__destroyCallbacks && Array.isArray(entry.__destroyCallbacks)) {
5086
+ for (var k = 0; k < entry.__destroyCallbacks.length; k++) {
5087
+ try { entry.__destroyCallbacks[k](); }
5088
+ catch (e) { console.warn('[stx] scope destroy error:', e); }
5089
+ }
5090
+ }
5091
+ delete scopes[id];
5092
+ }
5093
+ }
5094
+
5009
5095
  // ==========================================================================
5010
5096
  // Container Cleanup (for SPA navigation)
5011
5097
  // ==========================================================================
@@ -5073,6 +5159,11 @@ catch (e) { console.warn('[stx] destroy hook error:', e); }
5073
5159
  // through disposeSubtreeScopes so the same walk runs for SPA
5074
5160
  // navigation and for :if/:for-driven unmount (see #1727).
5075
5161
  disposeSubtreeScopes(container);
5162
+
5163
+ // 4. Reclaim entries the container walk can't see \u2014 components rendered
5164
+ // outside the swap container, or already detached before step 3 ran its
5165
+ // querySelectorAll. Without this the registry grew every navigation (#1775).
5166
+ sweepOrphanedScopes();
5076
5167
  }
5077
5168
 
5078
5169
  // Re-initialize components after SPA content swap.
@@ -5168,6 +5259,9 @@ catch (e) { console.warn('[stx] destroy callback error:', e); }
5168
5259
  document.body.querySelectorAll('[data-stx-scope]').forEach(function(el) {
5169
5260
  var scopeId = el.getAttribute('data-stx-scope');
5170
5261
  var scopeVars = window.stx._scopes && window.stx._scopes[scopeId];
5262
+ // Record the bound element for the orphan sweep (#1775) \u2014 see the
5263
+ // DOMContentLoaded walk above for the rationale.
5264
+ if (scopeVars) scopeVars.__el = el;
5171
5265
  // Deferred island reached via SPA navigation (#1746): its setup script is
5172
5266
  // inert (type="stx/island"), so the scope isn't registered yet. Arm the
5173
5267
  // hydration trigger (deferHydration) instead of skipping it \u2014 mirrors the