@stacksjs/stx 0.2.111 → 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.
package/dist/signals.js CHANGED
@@ -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
@@ -4865,6 +4881,10 @@ else {
4865
4881
  processedScopes.add(el);
4866
4882
 
4867
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;
4868
4888
 
4869
4889
  // Deferred island (#1746): its setup script (type="stx/island") hasn't run
4870
4890
  // yet, so the scope isn't registered. Arm the hydration trigger anyway \u2014
@@ -5024,6 +5044,54 @@ catch (e) {
5024
5044
  }
5025
5045
  }
5026
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
+
5027
5095
  // ==========================================================================
5028
5096
  // Container Cleanup (for SPA navigation)
5029
5097
  // ==========================================================================
@@ -5091,6 +5159,11 @@ catch (e) { console.warn('[stx] destroy hook error:', e); }
5091
5159
  // through disposeSubtreeScopes so the same walk runs for SPA
5092
5160
  // navigation and for :if/:for-driven unmount (see #1727).
5093
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();
5094
5167
  }
5095
5168
 
5096
5169
  // Re-initialize components after SPA content swap.
@@ -5186,6 +5259,9 @@ catch (e) { console.warn('[stx] destroy callback error:', e); }
5186
5259
  document.body.querySelectorAll('[data-stx-scope]').forEach(function(el) {
5187
5260
  var scopeId = el.getAttribute('data-stx-scope');
5188
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;
5189
5265
  // Deferred island reached via SPA navigation (#1746): its setup script is
5190
5266
  // inert (type="stx/island"), so the scope isn't registered yet. Arm the
5191
5267
  // hydration trigger (deferHydration) instead of skipping it \u2014 mirrors the