@stacksjs/stx 0.2.118 → 0.2.120

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 (60) hide show
  1. package/dist/builtins/attrs.d.ts +16 -0
  2. package/dist/builtins/attrs.js +16 -0
  3. package/dist/builtins/safe-image.js +2 -9
  4. package/dist/builtins/stx-image.js +2 -9
  5. package/dist/builtins/stx-link.js +2 -9
  6. package/dist/cli.js +1869 -824
  7. package/dist/client-script-bundler.d.ts +0 -11
  8. package/dist/client-script-bundler.js +26 -4
  9. package/dist/client-script.d.ts +24 -0
  10. package/dist/client-script.js +59 -5
  11. package/dist/component-registry.d.ts +1 -0
  12. package/dist/component-renderer.js +13 -5
  13. package/dist/composables/index.d.ts +1 -0
  14. package/dist/composables/index.js +1 -0
  15. package/dist/composables/use-scroll-lock.d.ts +13 -0
  16. package/dist/composables/use-scroll-lock.js +55 -0
  17. package/dist/composables/use-timer.d.ts +1 -1
  18. package/dist/composables/use-timer.js +13 -3
  19. package/dist/composition-api.d.ts +1 -1
  20. package/dist/composition-api.js +13 -4
  21. package/dist/craft-bridge.d.ts +80 -1
  22. package/dist/craft-bridge.js +80 -1
  23. package/dist/craft-components.d.ts +77 -3
  24. package/dist/craft-components.js +138 -9
  25. package/dist/craft.js +481 -73
  26. package/dist/dev-server/crosswind.d.ts +15 -1
  27. package/dist/dev-server/crosswind.js +12 -3
  28. package/dist/dev-server/serve-app.js +3 -3
  29. package/dist/document-shell.d.ts +17 -0
  30. package/dist/document-shell.js +72 -2
  31. package/dist/expressions.js +14 -7
  32. package/dist/head.d.ts +8 -0
  33. package/dist/head.js +11 -8
  34. package/dist/html-masking.d.ts +12 -0
  35. package/dist/html-masking.js +50 -15
  36. package/dist/index.d.ts +9 -1
  37. package/dist/index.js +9 -1
  38. package/dist/process.js +11 -3
  39. package/dist/pwa.js +1172 -431
  40. package/dist/runtime-globals.d.ts +7 -4
  41. package/dist/runtime-globals.js +10 -2
  42. package/dist/runtime-injection.js +15 -1
  43. package/dist/signal-processing.js +19 -28
  44. package/dist/signals-api.d.ts +2 -0
  45. package/dist/signals-api.js +9 -0
  46. package/dist/signals.js +802 -62
  47. package/dist/ssg.js +1161 -420
  48. package/dist/state-management.d.ts +1 -1
  49. package/dist/state-management.js +1 -1
  50. package/dist/strict-lint.d.ts +1 -1
  51. package/dist/strict-lint.js +1 -1
  52. package/dist/stx.d.ts +6 -0
  53. package/dist/style-scoping.js +10 -0
  54. package/dist/utils.js +5 -3
  55. package/dist/variable-extractor.js +14 -22
  56. package/dist/vue-template.js +3 -2
  57. package/dist/x-element.d.ts +2 -2
  58. package/dist/x-element.js +9 -8
  59. package/package.json +5 -5
  60. package/stx.d.ts +6 -0
package/dist/signals.js CHANGED
@@ -270,6 +270,12 @@ else {
270
270
  const val = target[prop];
271
271
  // If it's a signal or derived, call it to get the value
272
272
  if (val && typeof val === 'function' && (val._isSignal || val._isDerived)) {
273
+ // Loop variables are implementation-detail signals so keyed rows can
274
+ // update reactively. In template expressions they must behave like
275
+ // their item value, including for objects with fields named value,
276
+ // set, or subscribe. Otherwise item.value is mistaken for the
277
+ // signal compatibility API and resolves to the whole item object.
278
+ if (val._isStxLoopItem) return val();
273
279
  if (preserveSignal && preserveSignal(prop, val)) return val;
274
280
  return val();
275
281
  }
@@ -1274,7 +1280,7 @@ finally {
1274
1280
  return null;
1275
1281
  }
1276
1282
 
1277
- function toValue(expr, el, enableAutoUnwrap = true) {
1283
+ function toValue(expr, el, enableAutoUnwrap = true, extraScope = {}) {
1278
1284
  try {
1279
1285
  // Skip placeholder expressions like __TITLE__ (build-time placeholders)
1280
1286
  if (/^__[A-Z_]+__$/.test(expr.trim())) {
@@ -1282,7 +1288,7 @@ finally {
1282
1288
  }
1283
1289
  // First try component-level scope
1284
1290
  const elementScope = findElementScope(el || currentElement);
1285
- const baseScope = { ...globalHelpers, ...componentScope, ...(elementScope || {}) };
1291
+ const baseScope = { ...globalHelpers, ...componentScope, ...(elementScope || {}), ...extraScope };
1286
1292
 
1287
1293
  // Check for pipe syntax (Feature #2)
1288
1294
  const pipeResult = parsePipeExpression(expr, baseScope);
@@ -1361,8 +1367,8 @@ catch (e) {
1361
1367
  const [, varName, op, valueExpr] = assignMatch;
1362
1368
  const signal = scope[varName];
1363
1369
  if (signal && signal._isSignal) {
1364
- return () => {
1365
- const addValue = toValue(valueExpr, null, true);
1370
+ return ($event) => {
1371
+ const addValue = toValue(valueExpr, null, true, { ...scope, $event: $event });
1366
1372
  signal.update(n => {
1367
1373
  switch (op) {
1368
1374
  case '+': return n + addValue;
@@ -1383,8 +1389,8 @@ catch (e) {
1383
1389
  const [, varName, valueExpr] = simpleAssignMatch;
1384
1390
  const signal = scope[varName];
1385
1391
  if (signal && signal._isSignal) {
1386
- return () => {
1387
- const newValue = toValue(valueExpr, null, true);
1392
+ return ($event) => {
1393
+ const newValue = toValue(valueExpr, null, true, { ...scope, $event: $event });
1388
1394
  signal.set(newValue);
1389
1395
  };
1390
1396
  }
@@ -1467,8 +1473,16 @@ catch (e) {
1467
1473
  return;
1468
1474
  }
1469
1475
 
1470
- const fn = new Function(...Object.keys(scope), '$event', expr);
1471
- fn(...Object.values(scope), event);
1476
+ // Loop items arrive as signals (see createItemElements). Unwrap them for
1477
+ // the same reason the inline @event path does: \`item.value\` in a handler
1478
+ // means the item's field, not the signal API.
1479
+ const handlerScope = {};
1480
+ Object.keys(scope).forEach(function(k) {
1481
+ const v = scope[k];
1482
+ handlerScope[k] = (v && typeof v === 'function' && v._isStxLoopItem) ? v() : v;
1483
+ });
1484
+ const fn = new Function(...Object.keys(handlerScope), '$event', expr);
1485
+ fn(...Object.values(handlerScope), event);
1472
1486
  }
1473
1487
  catch (e) {
1474
1488
  // Event handlers fire on user interaction, not reactive re-runs \u2014 there's no
@@ -1625,10 +1639,22 @@ catch (e) {
1625
1639
  && /^\\s*\\(?\\s*\\w+(?:\\s*,\\s*\\w+)?\\s*\\)?\\s+(?:in|of)\\s+.+\\s*$/.test(expr);
1626
1640
  }
1627
1641
 
1642
+ function getElementAttributes(el) {
1643
+ if (!el || !el.attributes) return [];
1644
+ return Array.from(el.attributes).map(function(attr) {
1645
+ // Browser DOM exposes Attr records through NamedNodeMap. HappyDOM and
1646
+ // other standards-oriented DOM implementations may expose Map entries
1647
+ // for cloned nodes instead. Normalize both shapes so reactive nodes
1648
+ // created by :for hydrate exactly like their browser counterparts.
1649
+ if (Array.isArray(attr)) return { name: attr[0], value: attr[1] };
1650
+ return attr;
1651
+ });
1652
+ }
1653
+
1628
1654
  function getModelBinding(el) {
1629
1655
  if (!el || !el.attributes) return null;
1630
1656
  var prefixes = ['x-model', ':model', '@model'];
1631
- var attrs = Array.from(el.attributes);
1657
+ var attrs = getElementAttributes(el);
1632
1658
  for (var i = 0; i < attrs.length; i++) {
1633
1659
  var name = attrs[i] && attrs[i].name;
1634
1660
  if (!name) continue;
@@ -1640,17 +1666,112 @@ catch (e) {
1640
1666
  return null;
1641
1667
  }
1642
1668
 
1669
+ function readDeferredParentComponentProps(el) {
1670
+ var serialized = el.getAttribute('data-stx-deferred-parent-bindings');
1671
+ if (!serialized) return {};
1672
+ try {
1673
+ var parsed = JSON.parse(serialized);
1674
+ return parsed && typeof parsed === 'object' ? parsed : {};
1675
+ }
1676
+ catch (e) {
1677
+ return {};
1678
+ }
1679
+ }
1680
+
1681
+ function preserveDeferredParentComponentProps(el) {
1682
+ var names = (el.getAttribute('data-stx-parent-bindings') || '').split(/\\s+/).filter(Boolean);
1683
+ if (!names.length) return;
1684
+ var deferred = readDeferredParentComponentProps(el);
1685
+ names.forEach(function(name) {
1686
+ var sourceName = ':' + name;
1687
+ if (el.hasAttribute(sourceName))
1688
+ deferred[name] = el.getAttribute(sourceName);
1689
+ });
1690
+ if (Object.keys(deferred).length)
1691
+ el.setAttribute('data-stx-deferred-parent-bindings', JSON.stringify(deferred));
1692
+ }
1693
+
1694
+ function hasPendingLoopBoundary(el) {
1695
+ var loopBoundary = el;
1696
+ while (loopBoundary && loopBoundary !== document) {
1697
+ var loopExpression = loopBoundary.getAttribute && (
1698
+ loopBoundary.getAttribute('@for')
1699
+ || loopBoundary.getAttribute(':for')
1700
+ || loopBoundary.getAttribute('x-for')
1701
+ );
1702
+ if (loopExpression && isForDirectiveExpression(loopExpression))
1703
+ return true;
1704
+ loopBoundary = loopBoundary.parentElement || loopBoundary.parentNode;
1705
+ }
1706
+ return false;
1707
+ }
1708
+
1709
+ function hasPendingAncestorLoopBoundary(el) {
1710
+ var loopBoundary = el && (el.parentElement || el.parentNode);
1711
+ while (loopBoundary && loopBoundary !== document) {
1712
+ var loopExpression = loopBoundary.getAttribute && (
1713
+ loopBoundary.getAttribute('@for')
1714
+ || loopBoundary.getAttribute(':for')
1715
+ || loopBoundary.getAttribute('x-for')
1716
+ );
1717
+ if (loopExpression && isForDirectiveExpression(loopExpression))
1718
+ return true;
1719
+ loopBoundary = loopBoundary.parentElement || loopBoundary.parentNode;
1720
+ }
1721
+ return false;
1722
+ }
1723
+
1724
+ function syncComponentPropSignal(el, name, value) {
1725
+ if (!el || !el.getAttribute || value === undefined) return;
1726
+ var scopeId = el.getAttribute('data-stx-scope');
1727
+ var scopeVars = scopeId && window.stx._scopes && window.stx._scopes[scopeId];
1728
+ if (!scopeVars) return;
1729
+ var camelName = name.replace(/-([a-z0-9])/g, function(_, character) {
1730
+ return character.toUpperCase();
1731
+ });
1732
+ var propSignal = scopeVars[name] || scopeVars[camelName];
1733
+ if (!propSignal || !propSignal._isSignal || typeof propSignal.set !== 'function') return;
1734
+ var directValues = el.__stx_direct_prop_values || (el.__stx_direct_prop_values = {});
1735
+ var directValue = {
1736
+ raw: el.hasAttribute(name) ? el.getAttribute(name) : null,
1737
+ value: value,
1738
+ };
1739
+ directValues[name] = directValue;
1740
+ directValues[camelName] = directValue;
1741
+ // This runs inside the parent binding effect. Reading the child prop
1742
+ // signal normally would subscribe that effect to its own output. Parent
1743
+ // expressions that return a fresh array or object would then recurse:
1744
+ // syncing the child retriggers the parent, which creates another value.
1745
+ // Compare without dependency tracking so the effect only follows the
1746
+ // caller-owned signals used by the binding expression.
1747
+ if (!Object.is(peek(function() { return propSignal(); }), value))
1748
+ propSignal.set(value);
1749
+ }
1750
+
1643
1751
  function bindParentComponentProps(el, callerScope) {
1644
1752
  if (!el || !el.getAttribute || el.__stx_parent_props_bound) return;
1645
1753
  var names = (el.getAttribute('data-stx-parent-bindings') || '').split(/\\s+/).filter(Boolean);
1646
1754
  if (!names.length) return;
1755
+ if (hasPendingLoopBoundary(el)) {
1756
+ preserveDeferredParentComponentProps(el);
1757
+ return;
1758
+ }
1759
+ var deferred = readDeferredParentComponentProps(el);
1760
+ var bindings = names.map(function(name) {
1761
+ var sourceName = ':' + name;
1762
+ var expression = el.hasAttribute(sourceName) ? el.getAttribute(sourceName) : deferred[name];
1763
+ return typeof expression === 'string'
1764
+ ? { name: name, sourceName: sourceName, expression: expression }
1765
+ : null;
1766
+ }).filter(Boolean);
1767
+ if (!bindings.length) return;
1647
1768
  el.__stx_parent_props_bound = true;
1648
1769
  var scope = { ...globalHelpers, ...callerScope };
1649
1770
 
1650
- names.forEach(function(name) {
1651
- var sourceName = ':' + name;
1652
- if (!el.hasAttribute(sourceName)) return;
1653
- var expression = el.getAttribute(sourceName);
1771
+ bindings.forEach(function(binding) {
1772
+ var name = binding.name;
1773
+ var sourceName = binding.sourceName;
1774
+ var expression = binding.expression;
1654
1775
  effect(function() {
1655
1776
  var value;
1656
1777
  try {
@@ -1687,9 +1808,21 @@ catch (e) {
1687
1808
  }
1688
1809
  el.setAttribute(name, serialized);
1689
1810
  }
1811
+ // The component setup scope is already registered for normal and
1812
+ // cloned signal components. Update its prop signal directly so object
1813
+ // identity and synchronous Vue-like prop delivery do not depend on a
1814
+ // MutationObserver round trip through a serialized DOM attribute.
1815
+ // The attribute remains the public DOM reflection and observer fallback
1816
+ // for scopes that register after this binding pass.
1817
+ syncComponentPropSignal(el, name, value);
1690
1818
  });
1691
1819
  el.removeAttribute(sourceName);
1820
+ delete deferred[name];
1692
1821
  });
1822
+ if (Object.keys(deferred).length)
1823
+ el.setAttribute('data-stx-deferred-parent-bindings', JSON.stringify(deferred));
1824
+ else
1825
+ el.removeAttribute('data-stx-deferred-parent-bindings');
1693
1826
  }
1694
1827
 
1695
1828
  function resolveComponentCallerScope(el, pageScope) {
@@ -1720,11 +1853,69 @@ catch (e) {
1720
1853
  root.querySelectorAll('[data-stx-scope]').forEach(function(scopeEl) { scopeEls.push(scopeEl); });
1721
1854
  scopeEls.forEach(function(scopeEl) {
1722
1855
  if (scopeEl.__stx_parent_props_bound) return;
1856
+ // A component inside a descendant loop cannot resolve its caller props
1857
+ // until bindFor creates the iteration scope. Binding it while the outer
1858
+ // conditional is being detached would consume expressions such as
1859
+ // :status="deployment.status" before the deployment item exists, leaving every
1860
+ // later clone with the child's default prop. bindFor hydrates these
1861
+ // component scopes after insertion with the correct per-item scope.
1862
+ var current = scopeEl;
1863
+ while (current && current !== root) {
1864
+ if (current.hasAttribute
1865
+ && (current.hasAttribute('@for') || current.hasAttribute(':for') || current.hasAttribute('x-for'))) {
1866
+ preserveDeferredParentComponentProps(scopeEl);
1867
+ return;
1868
+ }
1869
+ current = current.parentElement || current.parentNode;
1870
+ }
1723
1871
  scopeEl.__stx_parent_scope = resolveComponentCallerScope(scopeEl, callerScope);
1724
1872
  bindParentComponentProps(scopeEl, scopeEl.__stx_parent_scope);
1725
1873
  });
1726
1874
  }
1727
1875
 
1876
+ // Component setup scripts are emitted immediately after their scoped root.
1877
+ // When that root itself carries :if, detaching only the element leaves the
1878
+ // sibling setup script outside the conditional node group. Scripts inserted
1879
+ // through innerHTML are inert, so the later hydration pass has no script to
1880
+ // execute and the component keeps raw directives and interpolations.
1881
+ function findConditionalComponentSetupSiblings(root) {
1882
+ if (!root || !root.hasAttribute || !root.hasAttribute('data-stx-scope')) return [];
1883
+ var scopeId = root.getAttribute('data-stx-scope');
1884
+ var templateScopeId = root.getAttribute('data-stx-template-scope') || scopeId;
1885
+ if (!scopeId) return [];
1886
+
1887
+ var scripts = [];
1888
+ var sibling = root.nextSibling;
1889
+ while (sibling) {
1890
+ var next = sibling.nextSibling;
1891
+ if (sibling.nodeType === Node.TEXT_NODE && !(sibling.textContent || '').trim()) {
1892
+ sibling = next;
1893
+ continue;
1894
+ }
1895
+ if (sibling.nodeType === Node.COMMENT_NODE) {
1896
+ sibling = next;
1897
+ continue;
1898
+ }
1899
+ if (sibling.nodeType === Node.ELEMENT_NODE
1900
+ && sibling.tagName === 'STYLE'
1901
+ && sibling.hasAttribute('data-stx-vendor')) {
1902
+ sibling = next;
1903
+ continue;
1904
+ }
1905
+ if (sibling.nodeType === Node.ELEMENT_NODE
1906
+ && sibling.tagName === 'SCRIPT'
1907
+ && sibling.hasAttribute('data-stx-scoped')
1908
+ && ((sibling.textContent || '').indexOf(scopeId) !== -1
1909
+ || (templateScopeId && (sibling.textContent || '').indexOf(templateScopeId) !== -1))) {
1910
+ scripts.push(sibling);
1911
+ sibling = next;
1912
+ continue;
1913
+ }
1914
+ break;
1915
+ }
1916
+ return scripts;
1917
+ }
1918
+
1728
1919
  function processElement(el, scope = componentScope) {
1729
1920
  // Debug: log every element with x-class or @click
1730
1921
  if (el.nodeType === Node.ELEMENT_NODE && el.hasAttribute) {
@@ -1739,6 +1930,17 @@ catch (e) {
1739
1930
  deferHydration(el, trigger, scope);
1740
1931
  return;
1741
1932
  }
1933
+ if (el.nodeType === Node.ELEMENT_NODE
1934
+ && el.hasAttribute
1935
+ && el.hasAttribute('data-stx-scope')
1936
+ && hasPendingAncestorLoopBoundary(el)) {
1937
+ preserveDeferredParentComponentProps(el);
1938
+ // Even when the component is itself a loop root, an ancestor loop must
1939
+ // create its caller item scope first. The ancestor's bindFor pass will
1940
+ // clone and hydrate this component with that scope, then its own loop can
1941
+ // evaluate expressions such as group.resources.
1942
+ return;
1943
+ }
1742
1944
 
1743
1945
  if (el.nodeType === Node.ELEMENT_NODE && el.tagName === 'BUTTON' && el.hasAttribute && el.hasAttribute('@click')) {
1744
1946
 
@@ -1964,9 +2166,9 @@ catch (e2) {
1964
2166
 
1965
2167
  // Handle attributes
1966
2168
  if (el.hasAttribute && (el.hasAttribute('x-class') || el.hasAttribute('@click'))) {
1967
- console.log('[stx] attr loop entry:', el.tagName, 'attrs:', Array.from(el.attributes).map(a => a.name).join(', '));
2169
+ console.log('[stx] attr loop entry:', el.tagName, 'attrs:', getElementAttributes(el).map(a => a.name).join(', '));
1968
2170
  }
1969
- Array.from(el.attributes).forEach(attr => {
2171
+ getElementAttributes(el).forEach(attr => {
1970
2172
  const name = attr.name;
1971
2173
  const value = attr.value;
1972
2174
  if (!name) return;
@@ -2008,6 +2210,7 @@ catch (e2) {
2008
2210
  el.value = desiredValue;
2009
2211
  if (desiredValue === '' && el.tagName !== 'OPTION') el.removeAttribute(attrName);
2010
2212
  else if (el.getAttribute(attrName) !== desiredValue) el.setAttribute(attrName, desiredValue);
2213
+ if (typeof el.__stx_model_sync === 'function') el.__stx_model_sync();
2011
2214
  };
2012
2215
  applyControlValue();
2013
2216
 
@@ -2032,6 +2235,24 @@ catch (e2) {
2032
2235
  }
2033
2236
  return;
2034
2237
  }
2238
+ // Checked, selected, and indeterminate are live DOM properties, just like value.
2239
+ // Updating only the HTML attribute leaves a user-touched checkbox or
2240
+ // radio in its previous state because the property becomes dirty.
2241
+ // Indeterminate has no corresponding effective HTML attribute at all.
2242
+ if ((attrName === 'checked' || attrName === 'selected' || attrName === 'indeterminate') && attrName in el) {
2243
+ var selectedState = Boolean(v);
2244
+ el[attrName] = selectedState;
2245
+ if (attrName === 'indeterminate') {
2246
+ el.removeAttribute(attrName);
2247
+ }
2248
+ else if (selectedState) {
2249
+ el.setAttribute(attrName, '');
2250
+ }
2251
+ else {
2252
+ el.removeAttribute(attrName);
2253
+ }
2254
+ return;
2255
+ }
2035
2256
  if (v === false || v === null || v === undefined) {
2036
2257
  el.removeAttribute(attrName);
2037
2258
  }
@@ -2076,6 +2297,14 @@ else if (name === 'ref' || name === ':ref' || name === 'x-ref' || name === 'data
2076
2297
  // Store ref in scope.$refs and componentScope.$refs
2077
2298
  if (scope.$refs) scope.$refs[value] = el;
2078
2299
  if (componentScope.$refs) componentScope.$refs[value] = el;
2300
+ // Also fill a same-named signal declared in the script block, so the
2301
+ // documented ref() + x-ref pair works and reading the signal returns
2302
+ // the element. Without this the signal stayed empty and every read of
2303
+ // it silently returned undefined.
2304
+ var refTarget = (scope && scope[value]) || componentScope[value];
2305
+ if (refTarget && typeof refTarget === 'function' && refTarget._isSignal && typeof refTarget.set === 'function') {
2306
+ refTarget.set(el);
2307
+ }
2079
2308
  }
2080
2309
  else if (name.startsWith('@') || name.startsWith(':')) {
2081
2310
  // Event handlers: @click, :click, @submit.prevent, :keydown.enter, etc.
@@ -2103,11 +2332,15 @@ else if (name === 'ref' || name === ':ref' || name === 'x-ref' || name === 'data
2103
2332
  console.log('[stx] binding event:', eventName, 'on', el.tagName, 'expr:', value.substring(0, 40));
2104
2333
  el.addEventListener(eventName, (event) => {
2105
2334
  console.log('[stx] event fired:', eventName, 'on', el.tagName);
2106
- // A component listener belongs to the component root. Ignore native
2107
- // events bubbling out of its internal markup, otherwise a form
2108
- // component with @submit fires the parent once for the native form
2109
- // submit and again for defineEmits('submit').
2110
- if (isForwardedComponentEvent && event.target !== el) return;
2335
+ // Vue-style component listeners fall through to the rendered native
2336
+ // root unless the child emits the same event. Signal components use
2337
+ // a scope wrapper, so native events arrive here from a descendant.
2338
+ // Ignore that native event only after defineEmits() has declared or
2339
+ // emitted the same name, preventing submit/click handlers from
2340
+ // firing once for the native event and again for the CustomEvent.
2341
+ var componentEmits = el.__stx_component_emits;
2342
+ if (isForwardedComponentEvent && event.target !== el
2343
+ && componentEmits && componentEmits[eventName]) return;
2111
2344
 
2112
2345
  // Vue-style component handlers receive the emitted payload directly.
2113
2346
  // Native root events still receive the Event object so server-only
@@ -2172,7 +2405,16 @@ else if (name === 'ref' || name === ':ref' || name === 'x-ref' || name === 'data
2172
2405
  // primitive reads.
2173
2406
  var unwrapVars = Object.keys(eventCapturedScope).map(function(k) {
2174
2407
  var v = eventCapturedScope[k];
2175
- if (v && typeof v === 'function' && v._isSignal && expressionUsesSignalApi(value, k)) {
2408
+ // A :for item is a signal only as an implementation detail, so
2409
+ // that keyed rows can update in place. Its \`.value\` is a field
2410
+ // on the item, never the signal compatibility API \u2014 the same
2411
+ // rule createAutoUnwrapProxy states for binding expressions.
2412
+ // Without the carve-out, expressionUsesSignalApi sees
2413
+ // \`option.value\` and hands the handler the raw signal, so a
2414
+ // handler like \`pick(option.value)\` receives the whole item.
2415
+ // It is not an error, so it lands silently: the wrong value is
2416
+ // stored and only surfaces much later.
2417
+ if (v && typeof v === 'function' && v._isSignal && !v._isStxLoopItem && expressionUsesSignalApi(value, k)) {
2176
2418
  return 'var ' + k + ' = __s["' + k + '"]';
2177
2419
  }
2178
2420
  return 'var ' + k + ' = __s["' + k + '"] && typeof __s["' + k + '"] === "function" && __s["' + k + '"]._isSignal ? __s["' + k + '"]() : __s["' + k + '"]';
@@ -2379,12 +2621,54 @@ catch (e) {
2379
2621
  }
2380
2622
  };
2381
2623
 
2382
- if (tag === 'input' && (type === 'checkbox' || type === 'radio')) {
2383
- effect(() => { el.checked = getValue(); });
2384
- el.addEventListener('change', () => setValue(el.checked));
2624
+ if (tag === 'input' && type === 'checkbox') {
2625
+ var syncCheckbox = () => {
2626
+ var current = getValue();
2627
+ el.checked = Array.isArray(current)
2628
+ ? current.some(function(item) { return String(item) === String(el.value); })
2629
+ : Boolean(current);
2630
+ };
2631
+ el.__stx_model_sync = syncCheckbox;
2632
+ effect(syncCheckbox);
2633
+ el.addEventListener('change', () => {
2634
+ var current = getValue();
2635
+ if (!Array.isArray(current)) {
2636
+ setValue(el.checked);
2637
+ return;
2638
+ }
2639
+
2640
+ var checkboxValue = coerceValue(el.value);
2641
+ var next = current.filter(function(item) { return String(item) !== String(checkboxValue); });
2642
+ if (el.checked) next.push(checkboxValue);
2643
+ setValue(next);
2644
+ });
2645
+ }
2646
+ else if (tag === 'input' && type === 'radio') {
2647
+ var syncRadio = () => { el.checked = String(getValue()) === String(el.value); };
2648
+ el.__stx_model_sync = syncRadio;
2649
+ effect(syncRadio);
2650
+ el.addEventListener('change', () => {
2651
+ if (el.checked) setValue(coerceValue(el.value));
2652
+ });
2385
2653
  }
2386
2654
  else if (tag === 'select') {
2387
- effect(() => { el.value = getValue(); });
2655
+ var syncSelect = () => { el.value = getValue() ?? ''; };
2656
+ el.__stx_model_sync = syncSelect;
2657
+ effect(syncSelect);
2658
+ if (!el.__stx_model_observer && typeof window.MutationObserver !== 'undefined') {
2659
+ el.__stx_model_observer = new window.MutationObserver(syncSelect);
2660
+ el.__stx_model_observer.observe(el, {
2661
+ childList: true,
2662
+ subtree: true,
2663
+ attributes: true,
2664
+ attributeFilter: ['value'],
2665
+ });
2666
+ el.__stx_destroy = el.__stx_destroy || [];
2667
+ el.__stx_destroy.push(function() {
2668
+ if (el.__stx_model_observer) el.__stx_model_observer.disconnect();
2669
+ el.__stx_model_observer = null;
2670
+ });
2671
+ }
2388
2672
  el.addEventListener('change', () => setValue(coerceValue(el.value)));
2389
2673
  }
2390
2674
  else {
@@ -2591,6 +2875,179 @@ else if (typeof value === 'string') {
2591
2875
  });
2592
2876
  }
2593
2877
 
2878
+ var __forComponentScopeCounter = 0;
2879
+ var __forComponentSetupFactories = new Map();
2880
+
2881
+ function forEachGroupElement(nodes, callback) {
2882
+ nodes.forEach(function(node) {
2883
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
2884
+ callback(node);
2885
+ if (node.querySelectorAll) {
2886
+ node.querySelectorAll('*').forEach(function(child) { callback(child); });
2887
+ }
2888
+ });
2889
+ }
2890
+
2891
+ // A signal component expanded inside <template :for> carries a compiled
2892
+ // data-stx-scope id and setup script. cloneNode() used to duplicate that id
2893
+ // for every row, so all instances shared the first child's props and state.
2894
+ // Give each cloned component tree its own ids before insertion, and rewrite
2895
+ // the setup scripts that target those ids.
2896
+ function remapForComponentScopes(nodes) {
2897
+ var scopeMap = {};
2898
+ forEachGroupElement(nodes, function(node) {
2899
+ if (!node.hasAttribute || !node.hasAttribute('data-stx-scope')) return;
2900
+ var oldId = node.getAttribute('data-stx-scope');
2901
+ if (!oldId) return;
2902
+ if (!scopeMap[oldId])
2903
+ scopeMap[oldId] = oldId + '_for_' + (++__forComponentScopeCounter);
2904
+ // Keep the compiler's original id through any additional structural
2905
+ // clones. DOM cloneNode() preserves attributes but not expando fields.
2906
+ if (!node.hasAttribute('data-stx-template-scope'))
2907
+ node.setAttribute('data-stx-template-scope', oldId);
2908
+ node.setAttribute('data-stx-scope', scopeMap[oldId]);
2909
+ });
2910
+
2911
+ var oldIds = Object.keys(scopeMap);
2912
+ if (!oldIds.length) return;
2913
+
2914
+ forEachGroupElement(nodes, function(node) {
2915
+ if (node.attributes) {
2916
+ Array.from(node.attributes).forEach(function(rawAttribute) {
2917
+ var attribute = Array.isArray(rawAttribute)
2918
+ ? { name: rawAttribute[0], value: rawAttribute[1] }
2919
+ : rawAttribute;
2920
+ if (!attribute || attribute.name === 'data-stx-scope' || attribute.name === 'data-stx-template-scope') return;
2921
+ var value = attribute.value;
2922
+ if (typeof value !== 'string') return;
2923
+ oldIds.forEach(function(oldId) {
2924
+ if (value.indexOf(oldId) !== -1)
2925
+ value = value.split(oldId).join(scopeMap[oldId]);
2926
+ });
2927
+ if (value !== attribute.value) node.setAttribute(attribute.name, value);
2928
+ });
2929
+ }
2930
+ });
2931
+ }
2932
+
2933
+ function hydrateComponentScopes(nodes, callerScope) {
2934
+ var roots = [];
2935
+ var scripts = [];
2936
+ forEachGroupElement(nodes, function(node) {
2937
+ if (node.hasAttribute && node.hasAttribute('data-stx-scope')) roots.push(node);
2938
+ if (node.tagName === 'SCRIPT' && node.hasAttribute('data-stx-scoped')) scripts.push(node);
2939
+ });
2940
+ if (!roots.length) return;
2941
+
2942
+ function hasPendingStructuralAncestor(root) {
2943
+ var cursor = root && root.parentElement;
2944
+ while (cursor && cursor.nodeType === Node.ELEMENT_NODE) {
2945
+ if (cursor.hasAttribute('@for')
2946
+ || cursor.hasAttribute(':for')
2947
+ || cursor.hasAttribute('x-for')
2948
+ || cursor.hasAttribute('@if')
2949
+ || cursor.hasAttribute(':if')
2950
+ || cursor.hasAttribute('x-if')
2951
+ || cursor.hasAttribute('@else-if')
2952
+ || cursor.hasAttribute(':else-if')
2953
+ || cursor.hasAttribute('x-else-if')
2954
+ || cursor.hasAttribute('@else')
2955
+ || cursor.hasAttribute(':else')
2956
+ || cursor.hasAttribute('x-else'))
2957
+ return true;
2958
+ cursor = cursor.parentElement;
2959
+ }
2960
+ return false;
2961
+ }
2962
+
2963
+ // Scripts cloned from template.content are inert in browsers. Compile each
2964
+ // distinct component setup body once, then run that cached function for
2965
+ // every loop instance. Temporarily restoring the template's original scope
2966
+ // id lets existing compiled setup code locate the correct root without
2967
+ // rewriting and reparsing the full script for every row.
2968
+ scripts.forEach(function(old) {
2969
+ var text = old.textContent || '';
2970
+ var pendingRoots = roots.filter(function(root) {
2971
+ var scopeId = root.getAttribute('data-stx-scope');
2972
+ var originalScopeId = root.getAttribute('data-stx-template-scope') || scopeId;
2973
+ return scopeId
2974
+ && originalScopeId
2975
+ && text.indexOf(originalScopeId) !== -1
2976
+ && !hasPendingStructuralAncestor(root)
2977
+ && !(window.stx._scopes && window.stx._scopes[scopeId]);
2978
+ });
2979
+ if (!pendingRoots.length) return;
2980
+
2981
+ var setup = __forComponentSetupFactories.get(text);
2982
+ if (!setup) {
2983
+ try {
2984
+ setup = new Function(text);
2985
+ __forComponentSetupFactories.set(text, setup);
2986
+ }
2987
+ catch (e) {
2988
+ console.error('[stx] cloned component setup compile error:', e);
2989
+ return;
2990
+ }
2991
+ }
2992
+
2993
+ pendingRoots.forEach(function(root) {
2994
+ var scopeId = root.getAttribute('data-stx-scope');
2995
+ var originalScopeId = root.getAttribute('data-stx-template-scope') || scopeId;
2996
+ if (!scopeId || !originalScopeId) return;
2997
+
2998
+ root.setAttribute('data-stx-scope', originalScopeId);
2999
+ try {
3000
+ setup();
3001
+ var registeredScope = window.stx._scopes && (window.stx._scopes[originalScopeId] || window.stx._scopes[scopeId]);
3002
+ if (registeredScope && originalScopeId !== scopeId) {
3003
+ delete window.stx._scopes[originalScopeId];
3004
+ window.stx._scopes[scopeId] = registeredScope;
3005
+ }
3006
+ }
3007
+ catch (e) {
3008
+ console.error('[stx] cloned component setup error:', e);
3009
+ }
3010
+ finally {
3011
+ root.setAttribute('data-stx-scope', scopeId);
3012
+ }
3013
+ });
3014
+
3015
+ // A component that is itself a structural source still needs its setup
3016
+ // sibling when bindFor/bindIf turns that source into a clone template.
3017
+ // Final row instances have no structural attribute and can discard it.
3018
+ var isStructuralSource = pendingRoots.some(function(root) {
3019
+ return root.hasAttribute('@for')
3020
+ || root.hasAttribute(':for')
3021
+ || root.hasAttribute('x-for')
3022
+ || root.hasAttribute('@if')
3023
+ || root.hasAttribute(':if')
3024
+ || root.hasAttribute('x-if');
3025
+ });
3026
+ if (!isStructuralSource && old.parentNode) old.parentNode.removeChild(old);
3027
+ });
3028
+
3029
+ roots.forEach(function(root) {
3030
+ var scopeId = root.getAttribute('data-stx-scope');
3031
+ var scopeVars = scopeId && window.stx._scopes && window.stx._scopes[scopeId];
3032
+ var resolvedCallerScope = resolveComponentCallerScope(root, callerScope);
3033
+ root.__stx_parent_scope = resolvedCallerScope;
3034
+ bindParentComponentProps(root, resolvedCallerScope);
3035
+ if (!scopeVars || root.__stx_disposers) return;
3036
+ scopeVars.__el = root;
3037
+ var childScope = { ...resolvedCallerScope, ...scopeVars };
3038
+ root.__stx_disposers = trackEffects(function() {
3039
+ processElement(root, childScope);
3040
+ });
3041
+ if (scopeVars.__mountCallbacks && !scopeVars.__mounted) {
3042
+ scopeVars.__mounted = true;
3043
+ scopeVars.__mountCallbacks.forEach(function(fn) {
3044
+ try { fn(); }
3045
+ catch (e) { console.error('[stx] onMount error:', e); }
3046
+ });
3047
+ }
3048
+ });
3049
+ }
3050
+
2594
3051
  function bindFor(el, passedScope = componentScope, attrName = '@for') {
2595
3052
  if (el.__stx_for_bound) return;
2596
3053
  el.__stx_for_bound = true;
@@ -2614,6 +3071,34 @@ else if (typeof value === 'string') {
2614
3071
 
2615
3072
  const placeholder = document.createComment('stx-for');
2616
3073
  const isTemplate = el.tagName === 'TEMPLATE';
3074
+ const componentSetupTemplates = [];
3075
+ if (!isTemplate && el.hasAttribute('data-stx-scope')) {
3076
+ const componentScopeId = el.getAttribute('data-stx-scope');
3077
+ const componentTemplateScopeId = el.getAttribute('data-stx-template-scope') || componentScopeId;
3078
+ let setupSibling = el.nextSibling;
3079
+ while (setupSibling) {
3080
+ const nextSetupSibling = setupSibling.nextSibling;
3081
+ if (setupSibling.nodeType === Node.TEXT_NODE && !(setupSibling.textContent || '').trim()) {
3082
+ setupSibling = nextSetupSibling;
3083
+ continue;
3084
+ }
3085
+ if (setupSibling.nodeType === Node.COMMENT_NODE) {
3086
+ setupSibling = nextSetupSibling;
3087
+ continue;
3088
+ }
3089
+ if (setupSibling.nodeType === Node.ELEMENT_NODE
3090
+ && setupSibling.tagName === 'SCRIPT'
3091
+ && setupSibling.hasAttribute('data-stx-scoped')
3092
+ && ((setupSibling.textContent || '').indexOf(componentScopeId) !== -1
3093
+ || (componentTemplateScopeId && (setupSibling.textContent || '').indexOf(componentTemplateScopeId) !== -1))) {
3094
+ componentSetupTemplates.push(setupSibling.cloneNode(true));
3095
+ setupSibling.remove();
3096
+ setupSibling = nextSetupSibling;
3097
+ continue;
3098
+ }
3099
+ break;
3100
+ }
3101
+ }
2617
3102
 
2618
3103
  // Check if element also has @if / :if - need to handle together
2619
3104
  const ifExpr = el.getAttribute('@if') || el.getAttribute(':if');
@@ -2650,6 +3135,11 @@ else if (typeof value === 'string') {
2650
3135
  const capturedScope = findElementScope(el) || findElementScope(parent);
2651
3136
 
2652
3137
  parent.insertBefore(placeholder, el);
3138
+ // Component setup scripts execute while the server-rendered loop source is
3139
+ // still in the document. That source becomes a template, not a live
3140
+ // instance, so reclaim all registered scopes before removing it. Every
3141
+ // rendered iteration receives remapped setup scripts and fresh scopes.
3142
+ disposeSubtreeScopes(el);
2653
3143
  parent.removeChild(el);
2654
3144
 
2655
3145
  // Capture :key expression BEFORE removing the attribute
@@ -2686,6 +3176,49 @@ else {
2686
3176
  let loadingElement = null;
2687
3177
  let emptyElement = null;
2688
3178
 
3179
+ // A <template :for> can contain another structural directive. That
3180
+ // nested directive replaces its source node with a placeholder and owns
3181
+ // additional live siblings around that placeholder. Track an explicit
3182
+ // boundary for every template iteration so keyed reuse moves the complete
3183
+ // live range, including nodes created by nested :for / :if bindings.
3184
+ // Moving only the original cloned nodes pulled static siblings past the
3185
+ // nested range on the next outer update (header, then rows became rows,
3186
+ // then header).
3187
+ const liveGroupNodes = (group) => {
3188
+ if (!isTemplate || !group || group.length < 2) return group || [];
3189
+ const start = group[0];
3190
+ const end = group[group.length - 1];
3191
+ if (!start || !end || start.parentNode !== parent || end.parentNode !== parent)
3192
+ return group.filter(groupNode => groupNode && groupNode.parentNode === parent);
3193
+
3194
+ const nodes = [];
3195
+ let currentNode = start;
3196
+ while (currentNode) {
3197
+ nodes.push(currentNode);
3198
+ if (currentNode === end) break;
3199
+ currentNode = currentNode.nextSibling;
3200
+ }
3201
+ return nodes;
3202
+ };
3203
+
3204
+ const removeGroup = (group) => {
3205
+ const liveNodes = liveGroupNodes(group);
3206
+ const liveSet = new Set(liveNodes);
3207
+ liveNodes.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
3208
+ group.forEach(groupNode => {
3209
+ if (liveSet.has(groupNode)) return;
3210
+ disposeSubtreeScopes(groupNode);
3211
+ groupNode.remove();
3212
+ });
3213
+ };
3214
+
3215
+ const clearRenderedItems = () => {
3216
+ currentGroups.forEach(removeGroup);
3217
+ currentElements = [];
3218
+ currentGroups = [];
3219
+ currentKeys = [];
3220
+ };
3221
+
2689
3222
  // Helper to evaluate with captured scope (auto-unwraps signals)
2690
3223
  // NOTE: This eagerly reads ALL signals in scope \u2014 use evalLazy inside effects
2691
3224
  // to avoid over-broad dependency tracking.
@@ -2779,6 +3312,7 @@ catch (e) {
2779
3312
  // The item is wrapped in a signal so bindings track it reactively.
2780
3313
  const createItemElements = (item, index, key) => {
2781
3314
  const itemSignal = state(item);
3315
+ itemSignal._isStxLoopItem = true;
2782
3316
  const indexSignal = state(index);
2783
3317
  if (key) itemSignalMap.set(key, { item: itemSignal, index: indexSignal });
2784
3318
 
@@ -2788,6 +3322,7 @@ catch (e) {
2788
3322
 
2789
3323
  const elements = [];
2790
3324
  if (isTemplate) {
3325
+ elements.push(document.createComment('stx-for-item-start'));
2791
3326
  Array.from(templateContent.childNodes).forEach(node => {
2792
3327
  const clone = node.cloneNode(true);
2793
3328
  if (clone.nodeType === 1) {
@@ -2797,11 +3332,16 @@ catch (e) {
2797
3332
  }
2798
3333
  elements.push(clone);
2799
3334
  });
3335
+ elements.push(document.createComment('stx-for-item-end'));
2800
3336
  } else {
2801
3337
  const clone = templateContent.cloneNode(true);
2802
3338
  clone.__stx_for_scope = itemScope;
2803
3339
  elements.push(clone);
3340
+ componentSetupTemplates.forEach(function(setupTemplate) {
3341
+ elements.push(setupTemplate.cloneNode(true));
3342
+ });
2804
3343
  }
3344
+ remapForComponentScopes(elements);
2805
3345
  return elements;
2806
3346
  };
2807
3347
 
@@ -2827,10 +3367,7 @@ catch (e) {
2827
3367
  const isLoading = evalLazy(loadingExpr);
2828
3368
  if (isLoading) {
2829
3369
  // Dispose scopes registered by items before they leave the DOM (#1727).
2830
- currentElements.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
2831
- currentElements = [];
2832
- currentGroups = [];
2833
- currentKeys = [];
3370
+ clearRenderedItems();
2834
3371
  showLoading();
2835
3372
  return;
2836
3373
  }
@@ -2876,10 +3413,7 @@ catch (e) {
2876
3413
  const ifValue = evalLazy(ifExpr);
2877
3414
  if (!ifValue) {
2878
3415
  // Dispose scopes registered by items before they leave the DOM (#1727).
2879
- currentElements.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
2880
- currentElements = [];
2881
- currentGroups = [];
2882
- currentKeys = [];
3416
+ clearRenderedItems();
2883
3417
  hideEmpty();
2884
3418
  return;
2885
3419
  }
@@ -2917,10 +3451,7 @@ catch (e) {
2917
3451
  // Check empty state (Feature #3)
2918
3452
  if (list.length === 0) {
2919
3453
  // Dispose scopes registered by items before they leave the DOM (#1727).
2920
- currentElements.forEach(e => { disposeSubtreeScopes(e); e.remove(); });
2921
- currentElements = [];
2922
- currentGroups = [];
2923
- currentKeys = [];
3454
+ clearRenderedItems();
2924
3455
  if (emptyExpr) {
2925
3456
  const emptyContent = evalLazy(emptyExpr);
2926
3457
  if (emptyContent && typeof emptyContent === 'string') {
@@ -2973,9 +3504,10 @@ catch (e) {
2973
3504
  // group's nodes. Its own placeholder owns reinsertion, so keyed reuse
2974
3505
  // must not resurrect that hidden node.
2975
3506
  const group = existing.shift();
2976
- group.forEach(el => {
2977
- if (el.parentNode === parent) parent.insertBefore(el, placeholder);
2978
- });
3507
+ const liveNodes = liveGroupNodes(group);
3508
+ const fragment = document.createDocumentFragment();
3509
+ liveNodes.forEach(groupNode => fragment.appendChild(groupNode));
3510
+ parent.insertBefore(fragment, placeholder);
2979
3511
  newElements.push(...group);
2980
3512
  newGroups.push(group);
2981
3513
  usedKeys.add(key);
@@ -2992,14 +3524,20 @@ catch (e) {
2992
3524
  const elements = createItemElements(list[i], i, key);
2993
3525
  elements.forEach(el => {
2994
3526
  parent.insertBefore(el, placeholder);
3527
+ if (tgReady && el.isConnected) tgEnter(el, tgName);
3528
+ });
3529
+ hydrateComponentScopes(elements, elements.find(function(node) {
3530
+ return node && node.__stx_for_scope;
3531
+ })?.__stx_for_scope || passedScope);
3532
+ elements.forEach(el => {
2995
3533
  if (el.nodeType === 1) {
2996
3534
  var itemScope = el.__stx_for_scope || passedScope;
2997
3535
  delete el.__stx_for_scope;
2998
- processElement(el, itemScope);
3536
+ if (!el.__stx_disposers && el.tagName !== 'SCRIPT')
3537
+ processElement(el, itemScope);
2999
3538
  el.removeAttribute('x-cloak');
3000
3539
  el.querySelectorAll('[x-cloak]').forEach(c => c.removeAttribute('x-cloak'));
3001
3540
  }
3002
- if (tgReady && el.isConnected) tgEnter(el, tgName);
3003
3541
  });
3004
3542
  newElements.push(...elements);
3005
3543
  newGroups.push(elements);
@@ -3011,11 +3549,28 @@ catch (e) {
3011
3549
  // remove() call (#1727).
3012
3550
  for (const [key, groups] of oldKeyMap) {
3013
3551
  if (!usedKeys.has(key)) {
3014
- const elements = groups.flat();
3015
3552
  // In a transition group, run the leave animation and let it remove the
3016
3553
  // node; otherwise remove immediately. Scopes are disposed up-front
3017
3554
  // either way (their destroy hooks shouldn't wait on a CSS transition).
3018
- elements.forEach(el => { disposeSubtreeScopes(el); if (!(tgReady && tgLeave(el, tgName))) el.remove(); });
3555
+ groups.forEach((group) => {
3556
+ if (tgReady) {
3557
+ const liveNodes = liveGroupNodes(group);
3558
+ const transitioned = new Set();
3559
+ liveNodes.forEach((el) => {
3560
+ disposeSubtreeScopes(el);
3561
+ if (tgLeave(el, tgName)) transitioned.add(el);
3562
+ else el.remove();
3563
+ });
3564
+ group.forEach((el) => {
3565
+ if (transitioned.has(el) || liveNodes.includes(el)) return;
3566
+ disposeSubtreeScopes(el);
3567
+ el.remove();
3568
+ });
3569
+ }
3570
+ else {
3571
+ removeGroup(group);
3572
+ }
3573
+ });
3019
3574
  itemSignalMap.delete(key);
3020
3575
  }
3021
3576
  }
@@ -3214,9 +3769,26 @@ catch (e2) {
3214
3769
  (function (branch) {
3215
3770
  setTimeout(function () {
3216
3771
  var childScope = { ...globalHelpers, ...capturedComponentScope, ...(branch.capturedElementScope || {}) };
3772
+ // Components compiled inside any branch of an if/else-if/else
3773
+ // chain carry scoped setup scripts with them. Branch nodes are
3774
+ // detached before those scripts can execute, so simply
3775
+ // re-inserting the selected branch leaves component props,
3776
+ // events, directives, and projected slot expressions inert.
3777
+ // Hydrate those scopes before the normal subtree walk, matching
3778
+ // the single-template :if and :for insertion paths.
3779
+ var previouslyTrackedNodes = new Set();
3780
+ branch.nodes.forEach(function (n) {
3781
+ if (n && n.__stx_disposers) previouslyTrackedNodes.add(n);
3782
+ });
3783
+ hydrateComponentScopes(branch.nodes, childScope);
3217
3784
  branch.nodes.forEach(function (n) {
3218
3785
  if (n.nodeType !== 1) return; // whitespace/text between template children
3219
- processElement(n, childScope);
3786
+ // A scoped component receives its disposer in the outer scope
3787
+ // walk immediately after processElement returns from binding
3788
+ // this conditional. That first pass intentionally stops at
3789
+ // :if, so a disposer does not mean its children were hydrated.
3790
+ if (previouslyTrackedNodes.has(n) || !n.__stx_disposers)
3791
+ processElement(n, childScope);
3220
3792
  n.removeAttribute('x-cloak');
3221
3793
  n.querySelectorAll('[x-cloak]').forEach(function (c) { c.removeAttribute('x-cloak'); });
3222
3794
  });
@@ -3245,10 +3817,11 @@ catch (e2) {
3245
3817
 
3246
3818
  const placeholder = document.createComment('stx-if');
3247
3819
  let isInserted = true;
3248
- let currentNodes = [];
3249
3820
 
3250
3821
  // Handle <template> elements specially - clone their content
3251
3822
  const isTemplate = el.tagName === 'TEMPLATE';
3823
+ const componentSetupSiblings = isTemplate ? [] : findConditionalComponentSetupSiblings(el);
3824
+ let currentNodes = isTemplate ? [] : [el, ...componentSetupSiblings];
3252
3825
 
3253
3826
  // Capture BOTH element scope AND passedScope NOW before anything changes
3254
3827
  // passedScope may contain @for iteration variables or parent component signals
@@ -3323,6 +3896,28 @@ catch (e2) {
3323
3896
  childrenProcessed = true;
3324
3897
  };
3325
3898
 
3899
+ // A <template :if> inserts clones of template.content rather than the
3900
+ // template element itself. Those clones still need the normal directive
3901
+ // and interpolation pass. Previously the initially-visible branch was
3902
+ // inserted and left inert, so bindings such as :text and {{ expression }}
3903
+ // leaked into the rendered UI until the condition toggled.
3904
+ const processTemplateNodes = (nodes) => {
3905
+ const childScope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
3906
+ // Components compiled inside a <template :if> carry setup scripts in the
3907
+ // template content. Browser-cloned scripts are inert, so register their
3908
+ // scopes and forwarded props before walking the inserted branch. This is
3909
+ // the same component hydration path used by <template :for>.
3910
+ hydrateComponentScopes(nodes, childScope);
3911
+ nodes.forEach(node => {
3912
+ if (!node.__stx_disposers && node.tagName !== 'SCRIPT')
3913
+ processElement(node, childScope);
3914
+ if (node.nodeType === 1) {
3915
+ node.removeAttribute('x-cloak');
3916
+ node.querySelectorAll('[x-cloak]').forEach(c => c.removeAttribute('x-cloak'));
3917
+ }
3918
+ });
3919
+ };
3920
+
3326
3921
  // Evaluate the :if expression \u2014 use direct signal read for simple refs,
3327
3922
  // falling back to evalExpr for complex expressions
3328
3923
  const fullScope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
@@ -3351,16 +3946,8 @@ catch (e2) {
3351
3946
  currentNodes = Array.from(el.content.childNodes).map(n => n.cloneNode(true));
3352
3947
  const insertionAnchor = placeholder.nextSibling;
3353
3948
  currentNodes.forEach(node => parent.insertBefore(node, insertionAnchor));
3354
- // Process the new nodes \u2014 use LIVE componentScope so all functions are available
3355
- const childScope = { ...componentScope, ...(capturedElementScope || {}) };
3356
- currentNodes.forEach(node => {
3357
- if (node.nodeType === 1) {
3358
- processElement(node, childScope);
3359
- // Remove x-cloak from processed clones
3360
- node.removeAttribute('x-cloak');
3361
- node.querySelectorAll('[x-cloak]').forEach(c => c.removeAttribute('x-cloak'));
3362
- }
3363
- });
3949
+ processTemplateNodes(currentNodes);
3950
+ childrenProcessed = true;
3364
3951
  // Stamp insertion time for click propagation guard
3365
3952
  currentNodes.forEach(function(n) { if (n.nodeType === 1) n.__stx_shown_at = performance.now(); });
3366
3953
  isInserted = true;
@@ -3372,12 +3959,22 @@ else if (!value && isInserted) {
3372
3959
  currentNodes.forEach(node => node.remove());
3373
3960
  currentNodes = [];
3374
3961
  isInserted = false;
3962
+ childrenProcessed = false;
3963
+ }
3964
+ else if (value && isInserted && !childrenProcessed) {
3965
+ // Initial template content is cloned before the effect runs. Process
3966
+ // it after the condition is confirmed so hidden branches stay inert.
3967
+ childrenProcessed = true;
3968
+ setTimeout(function() {
3969
+ if (isInserted) processTemplateNodes(currentNodes);
3970
+ }, 0);
3375
3971
  }
3376
3972
  }
3377
3973
  else {
3378
3974
  if (value && !isInserted) {
3379
3975
  console.log('[stx] bindIf INSERTING element for :if=' + expr);
3380
- parent.insertBefore(el, placeholder.nextSibling);
3976
+ const insertionAnchor = placeholder.nextSibling;
3977
+ currentNodes.forEach(node => parent.insertBefore(node, insertionAnchor));
3381
3978
  el.__stx_shown_at = performance.now();
3382
3979
  isInserted = true;
3383
3980
  }
@@ -3392,7 +3989,7 @@ else if (!value && isInserted) {
3392
3989
  // once (#1737). Permanent disposal stays in bindFor (item removal)
3393
3990
  // and cleanupContainer (SPA navigation). The double-bind guards make
3394
3991
  // re-show idempotent, so toggling doesn't leak.
3395
- el.remove();
3992
+ currentNodes.forEach(node => node.remove());
3396
3993
  isInserted = false;
3397
3994
  console.log('[stx] bindIf REMOVED, el.isConnected:', el.isConnected);
3398
3995
  }
@@ -3407,7 +4004,15 @@ else if (!value && isInserted) {
3407
4004
  childrenProcessed = true;
3408
4005
  setTimeout(function() {
3409
4006
  var childScope = { ...globalHelpers, ...capturedComponentScope, ...(capturedElementScope || {}) };
3410
- processElement(el, childScope);
4007
+ // A false single-element conditional is detached before the
4008
+ // DOMContentLoaded component-scope pass. When it becomes visible,
4009
+ // hydrate any component roots in the restored subtree first so
4010
+ // projected slot bindings and component-local directives receive
4011
+ // the same lifecycle as components inserted by template :if.
4012
+ var wasTrackedBeforeConditionalHydration = !!el.__stx_disposers;
4013
+ hydrateComponentScopes(currentNodes, childScope);
4014
+ if (wasTrackedBeforeConditionalHydration || !el.__stx_disposers)
4015
+ processElement(el, childScope);
3411
4016
  // Remove x-cloak from the inserted subtree \u2014 the initial
3412
4017
  // cloak removal (after processElement on the root) already
3413
4018
  // ran before this deferred processing, so newly-inserted
@@ -3542,6 +4147,15 @@ else if (timer === null) {
3542
4147
  }
3543
4148
 
3544
4149
  function useInterval(interval, options) {
4150
+ // Accept the callback-first form too \u2014 useInterval(fn, 1000) is what most
4151
+ // callers reach for, and silently ignoring the callback (the previous
4152
+ // behaviour) produced a timer that ticked into the void.
4153
+ var callback = null;
4154
+ if (typeof interval === 'function') {
4155
+ callback = interval;
4156
+ interval = typeof options === 'number' ? options : 1000;
4157
+ options = {};
4158
+ }
3545
4159
  interval = interval || 1000;
3546
4160
  options = options || {};
3547
4161
  var count = 0;
@@ -3569,6 +4183,7 @@ else if (timer === null) {
3569
4183
  listeners.forEach(function(fn) { fn(count); });
3570
4184
  resume();
3571
4185
  }
4186
+ if (callback) listeners.push(callback);
3572
4187
  resume();
3573
4188
  onDestroy(function() { pause(); listeners = []; });
3574
4189
  return {
@@ -3659,8 +4274,16 @@ else if (timer === null) {
3659
4274
  }
3660
4275
 
3661
4276
  function useClickOutside(target, handler) {
4277
+ function resolveTarget() {
4278
+ if (typeof target === 'string') return document.querySelector(target);
4279
+ if (target && typeof target === 'object') {
4280
+ if ('current' in target) return target.current;
4281
+ if ('value' in target) return target.value;
4282
+ }
4283
+ return target;
4284
+ }
3662
4285
  function listener(event) {
3663
- var el = typeof target === 'string' ? document.querySelector(target) : target;
4286
+ var el = resolveTarget();
3664
4287
  if (!el) return;
3665
4288
  if (el === event.target || el.contains(event.target)) return;
3666
4289
  handler(event);
@@ -3707,6 +4330,15 @@ else if (timer === null) {
3707
4330
  };
3708
4331
  }
3709
4332
 
4333
+ // Queue work after the current synchronous signal/effect flush. This mirrors
4334
+ // the Composition API nextTick contract and gives template refs a chance to
4335
+ // resolve after a structural directive inserts their elements.
4336
+ function nextTick(fn) {
4337
+ return Promise.resolve().then(function() {
4338
+ if (fn) fn();
4339
+ });
4340
+ }
4341
+
3710
4342
  function useAsync(fn, options) {
3711
4343
  options = options || {};
3712
4344
  var asyncState = 'idle';
@@ -3845,6 +4477,61 @@ catch (e) {}
3845
4477
  return matches;
3846
4478
  }
3847
4479
 
4480
+ var scrollLockStates = new WeakMap();
4481
+
4482
+ function resolveScrollLockTarget(target) {
4483
+ var value = target;
4484
+ if (typeof value === 'function') value = value();
4485
+ if (value && typeof value === 'object' && 'current' in value) value = value.current;
4486
+ if (value && typeof value === 'object' && !value.nodeType && 'value' in value) value = value.value;
4487
+ return value || document.body || document.documentElement;
4488
+ }
4489
+
4490
+ function acquireScrollLock(element) {
4491
+ if (!element) return;
4492
+ var existing = scrollLockStates.get(element);
4493
+ if (existing) {
4494
+ existing.count += 1;
4495
+ return;
4496
+ }
4497
+ scrollLockStates.set(element, { count: 1, overflow: element.style.overflow });
4498
+ element.style.overflow = 'hidden';
4499
+ }
4500
+
4501
+ function releaseScrollLock(element) {
4502
+ if (!element) return;
4503
+ var existing = scrollLockStates.get(element);
4504
+ if (!existing) return;
4505
+ existing.count -= 1;
4506
+ if (existing.count > 0) return;
4507
+ element.style.overflow = existing.overflow;
4508
+ scrollLockStates.delete(element);
4509
+ }
4510
+
4511
+ function useScrollLock(target) {
4512
+ var locked = state(false);
4513
+ var lockedElement = null;
4514
+ var unsubscribe = locked.subscribe(function(value) {
4515
+ if (value) {
4516
+ if (lockedElement) return;
4517
+ lockedElement = resolveScrollLockTarget(target);
4518
+ acquireScrollLock(lockedElement);
4519
+ }
4520
+ else if (lockedElement) {
4521
+ releaseScrollLock(lockedElement);
4522
+ lockedElement = null;
4523
+ }
4524
+ });
4525
+ onDestroy(function() {
4526
+ unsubscribe();
4527
+ if (lockedElement) {
4528
+ releaseScrollLock(lockedElement);
4529
+ lockedElement = null;
4530
+ }
4531
+ });
4532
+ return locked;
4533
+ }
4534
+
3848
4535
  function usePreferredDark() { return useMediaQuery('(prefers-color-scheme: dark)'); }
3849
4536
  function usePreferredLight() { return useMediaQuery('(prefers-color-scheme: light)'); }
3850
4537
  function usePreferredReducedMotion() { return useMediaQuery('(prefers-reduced-motion: reduce)'); }
@@ -3932,6 +4619,30 @@ catch (e) {}
3932
4619
  return s;
3933
4620
  }
3934
4621
 
4622
+ var __stxGeneratedIdCounter = 0;
4623
+
4624
+ // Stable, scope-aware DOM ids for component authoring. A component setup
4625
+ // runs with window.__STX_CURRENT_ELEMENT__ pointing at its compiled root, so
4626
+ // the data-stx scope id is already the canonical per-instance identity. The
4627
+ // local counter supports multiple useId() calls in one component while the
4628
+ // global fallback covers page scripts and manually-mounted roots.
4629
+ function useId(prefix) {
4630
+ var normalizedPrefix = String(prefix || 'stx')
4631
+ .trim()
4632
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
4633
+ .replace(/^-+|-+$/g, '') || 'stx';
4634
+ var root = window.__STX_CURRENT_ELEMENT__;
4635
+ if (root && root.getAttribute) {
4636
+ var scopeId = root.getAttribute('data-stx-scope') || root.getAttribute('data-stx') || '';
4637
+ if (scopeId) {
4638
+ root.__stx_id_counter = (root.__stx_id_counter || 0) + 1;
4639
+ return normalizedPrefix + '-' + String(scopeId).replace(/[^a-zA-Z0-9_-]+/g, '-') + '-' + root.__stx_id_counter;
4640
+ }
4641
+ }
4642
+ __stxGeneratedIdCounter++;
4643
+ return normalizedPrefix + '-' + __stxGeneratedIdCounter;
4644
+ }
4645
+
3935
4646
  // Reactive prop binding. Bridges the gap between a parent's clientReactive
3936
4647
  // attribute (e.g. :open="modalOpen()" on a component) and the component's
3937
4648
  // internal state. Returns a signal whose value tracks the named attribute
@@ -4018,7 +4729,19 @@ catch (e) {}
4018
4729
  var raw = attribute.raw;
4019
4730
  if (raw === lastRaw) return;
4020
4731
  lastRaw = raw;
4021
- var next = hasAttr ? parse(raw) : staticValue;
4732
+ var directValues = root.__stx_direct_prop_values;
4733
+ var directValue = directValues && directValues[name];
4734
+ var next;
4735
+ if (directValue && directValue.raw === raw) {
4736
+ next = directValue.value;
4737
+ delete directValues[name];
4738
+ for (var directKey in directValues) {
4739
+ if (directValues[directKey] === directValue) delete directValues[directKey];
4740
+ }
4741
+ }
4742
+ else {
4743
+ next = hasAttr ? parse(raw) : staticValue;
4744
+ }
4022
4745
  if (next !== s()) s.set(next);
4023
4746
  });
4024
4747
  observer.observe(root, { attributes: true, attributeFilter: attributeNames });
@@ -4561,16 +5284,19 @@ catch (e) {}
4561
5284
  useCounter,
4562
5285
  useClickOutside,
4563
5286
  useFocus,
5287
+ nextTick,
4564
5288
  useAsync,
4565
5289
  useLocalStorage,
4566
5290
  useSessionStorage,
4567
5291
  useCookie,
5292
+ useId,
4568
5293
  useReactiveProp,
4569
5294
  useEventListener,
4570
5295
  useWebSocket,
4571
5296
  useColorMode,
4572
5297
  useDark,
4573
5298
  useMediaQuery,
5299
+ useScrollLock,
4574
5300
  usePreferredDark,
4575
5301
  usePreferredLight,
4576
5302
  usePreferredReducedMotion,
@@ -4615,10 +5341,23 @@ catch (e) {}
4615
5341
  }
4616
5342
  return result;
4617
5343
  },
4618
- defineEmits: function() {
5344
+ defineEmits: function(events) {
4619
5345
  var el = window.__STX_CURRENT_ELEMENT__ || null;
5346
+ var declaredEvents = Array.isArray(events)
5347
+ ? events
5348
+ : (events && typeof events === 'object' ? Object.keys(events) : []);
5349
+ if (el && declaredEvents.length) {
5350
+ el.__stx_component_emits = el.__stx_component_emits || Object.create(null);
5351
+ declaredEvents.forEach(function(event) {
5352
+ el.__stx_component_emits[event] = true;
5353
+ });
5354
+ }
4620
5355
  return function(event, payload) {
4621
5356
  var target = el || document.body;
5357
+ if (el) {
5358
+ el.__stx_component_emits = el.__stx_component_emits || Object.create(null);
5359
+ el.__stx_component_emits[event] = true;
5360
+ }
4622
5361
  target.dispatchEvent(new CustomEvent(event, { detail: payload, bubbles: true, cancelable: true }));
4623
5362
  };
4624
5363
  },
@@ -5075,6 +5814,7 @@ else {
5075
5814
  window.useCounter = useCounter;
5076
5815
  window.useClickOutside = useClickOutside;
5077
5816
  window.useFocus = useFocus;
5817
+ window.nextTick = nextTick;
5078
5818
  window.useAsync = useAsync;
5079
5819
  window.useLocalStorage = useLocalStorage;
5080
5820
  window.useEventListener = useEventListener;