webspresso 0.0.111 → 0.0.113

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.
@@ -24,7 +24,7 @@
24
24
  * external package is not installed in `node_modules`.
25
25
  */
26
26
 
27
- const { isArray, getByPath, setByPath } = require('../utils');
27
+ const { isArray, getByPath, setByPath, parseDelayMs } = require('../utils');
28
28
 
29
29
  const PluginType = {
30
30
  Attribute: 1,
@@ -218,6 +218,22 @@ function createSignalsEngine() {
218
218
  }
219
219
  return out;
220
220
  }
221
+
222
+ getSignalCount() {
223
+ return this._nodes.size;
224
+ }
225
+
226
+ getSubscriberCount() {
227
+ const runners = new Set();
228
+ for (const node of this._nodes.values()) {
229
+ for (const sub of node._subs) {
230
+ if (!sub.disposed) {
231
+ runners.add(sub);
232
+ }
233
+ }
234
+ }
235
+ return runners.size;
236
+ }
221
237
  }
222
238
 
223
239
  return {
@@ -687,22 +703,124 @@ const CLIENT_OWNED_TEXT_ATTR = 'data-w-text';
687
703
  const CLIENT_OWNED_SHOW_ATTR = 'data-w-show';
688
704
  const CLIENT_OWNED_DISABLED_ATTR = 'data-w-disabled';
689
705
  const CLIENT_OWNED_MODEL_ATTR = 'data-w-model';
706
+ const CLIENT_OWNED_CLASS_ATTR = 'data-w-class';
707
+
708
+ function hasClassBindingAttr(el) {
709
+ if (!el || !el.attributes) return false;
710
+ if (el.hasAttribute && el.hasAttribute(CLIENT_OWNED_CLASS_ATTR)) return true;
711
+ for (let i = 0; i < el.attributes.length; i++) {
712
+ if (el.attributes[i].name.startsWith('data-w-class:')) return true;
713
+ }
714
+ return false;
715
+ }
690
716
 
691
717
  /**
692
718
  * Determine whether a node has client bindings owning specific DOM properties.
693
719
  */
694
720
  function getClientOwnership(el) {
695
721
  if (!el || typeof el.hasAttribute !== 'function') {
696
- return { text: false, show: false, disabled: false, model: false };
722
+ return { text: false, show: false, disabled: false, model: false, classBinding: false };
697
723
  }
698
724
  return {
699
725
  text: el.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
700
726
  show: el.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
701
727
  disabled: el.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
702
728
  model: el.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
729
+ classBinding: hasClassBindingAttr(el),
703
730
  };
704
731
  }
705
732
 
733
+ /**
734
+ * Determine ignore mode for third-party widget DOM ownership (`data-w-ignore`).
735
+ * - `'children'` (default `data-w-ignore` or `data-w-ignore="children"`): sync root attributes, never morph inner DOM.
736
+ * - `'self'` (`data-w-ignore="self"`): never sync root attributes, morph inner DOM.
737
+ * - `'all'` (`data-w-ignore="all"`): never sync root attributes AND never morph inner DOM.
738
+ * Note: Parent removal/key-change STILL removes the node and triggers `onBeforeNodeRemove` cleanup.
739
+ */
740
+ function getIgnoreMode(el) {
741
+ if (!el || typeof el.hasAttribute !== 'function' || !el.hasAttribute('data-w-ignore')) {
742
+ return null;
743
+ }
744
+ const raw = (el.getAttribute('data-w-ignore') || '').trim().toLowerCase();
745
+ if (raw === 'self') return 'self';
746
+ if (raw === 'all' || raw === 'both') return 'all';
747
+ return 'children';
748
+ }
749
+
750
+ function emitOwnershipConflict(debug, el, property, token, message) {
751
+ const isDev = typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production';
752
+ if (!debug && !isDev) return;
753
+ const tag = el && el.tagName ? el.tagName.toLowerCase() : 'element';
754
+ const idPart = el && el.id ? `#${el.id}` : '';
755
+ const payload = {
756
+ element: `${tag}${idPart}`,
757
+ property,
758
+ token: token || null,
759
+ message,
760
+ };
761
+ if (debug && typeof debug.recordEvent === 'function') {
762
+ debug.recordEvent('ownership:conflict', payload);
763
+ } else if (isDev && typeof console !== 'undefined' && typeof console.warn === 'function') {
764
+ console.warn(`[webspresso:ownership] ${message}`, payload);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Sync `class` attribute from `fromEl` onto `toEl`, preserving client-owned class tokens
770
+ * managed by `data-w-class`, `data-w-class:*`, and `data-w-loading-class`.
771
+ */
772
+ function syncClassAttribute(toEl, fromEl, debug) {
773
+ const ownedClassState = toEl.__wOwnedClassState;
774
+ const fromRaw = fromEl.getAttribute('class');
775
+
776
+ if (!ownedClassState || ownedClassState.size === 0) {
777
+ if (fromRaw === null) {
778
+ if (toEl.hasAttribute('class')) toEl.removeAttribute('class');
779
+ } else if (toEl.getAttribute('class') !== fromRaw) {
780
+ toEl.setAttribute('class', fromRaw);
781
+ }
782
+ return;
783
+ }
784
+
785
+ const serverTokens = (fromRaw || '').trim().split(/\s+/).filter(Boolean);
786
+ const merged = [];
787
+ const seen = new Set();
788
+
789
+ for (let i = 0; i < serverTokens.length; i++) {
790
+ const tok = serverTokens[i];
791
+ if (ownedClassState.has(tok)) {
792
+ emitOwnershipConflict(
793
+ debug,
794
+ toEl,
795
+ 'class',
796
+ tok,
797
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server class attribute and client data-w-class manage token "${tok}".`
798
+ );
799
+ continue;
800
+ }
801
+ if (!seen.has(tok)) {
802
+ seen.add(tok);
803
+ merged.push(tok);
804
+ }
805
+ }
806
+
807
+ for (const [tok, active] of ownedClassState.entries()) {
808
+ if (active && !seen.has(tok)) {
809
+ seen.add(tok);
810
+ merged.push(tok);
811
+ }
812
+ }
813
+
814
+ const nextClass = merged.join(' ');
815
+ if (nextClass) {
816
+ if (toEl.getAttribute('class') !== nextClass) {
817
+ toEl.setAttribute('class', nextClass);
818
+ }
819
+ } else if (toEl.hasAttribute('class')) {
820
+ toEl.removeAttribute('class');
821
+ }
822
+ }
823
+
706
824
  /**
707
825
  * Check if a form element currently holds unsaved draft values.
708
826
  */
@@ -759,16 +877,62 @@ function getMorphNodeKey(node) {
759
877
  /**
760
878
  * Sync attributes from `fromEl` onto `toEl` while respecting client-owned properties
761
879
  * and preserving active focus / form draft states.
880
+ * When a client binding attribute (`data-w-show`, `data-w-disabled`, `data-w-class`, etc.)
881
+ * is removed in `fromEl`, ownership is immediately released so server HTML can reclaim it.
762
882
  */
763
- function syncElementAttributes(toEl, fromEl, ownership) {
883
+ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false, debug = null) {
764
884
  const fromAttrs = fromEl.attributes;
765
885
  const toAttrs = Array.from(toEl.attributes);
766
886
 
887
+ const effectiveOwnership = {
888
+ text: ownership.text && fromEl.hasAttribute(CLIENT_OWNED_TEXT_ATTR),
889
+ show: ownership.show && fromEl.hasAttribute(CLIENT_OWNED_SHOW_ATTR),
890
+ disabled: ownership.disabled && fromEl.hasAttribute(CLIENT_OWNED_DISABLED_ATTR),
891
+ model: ownership.model && fromEl.hasAttribute(CLIENT_OWNED_MODEL_ATTR),
892
+ classBinding: ownership.classBinding && hasClassBindingAttr(fromEl),
893
+ };
894
+
895
+ if (ownership.classBinding && !effectiveOwnership.classBinding && toEl.__wOwnedClassState) {
896
+ toEl.__wOwnedClassState.clear();
897
+ delete toEl.__wOwnedClassState;
898
+ }
899
+
900
+ const wasHiddenByShow = effectiveOwnership.show && toEl.hasAttribute('hidden');
901
+
902
+ if (effectiveOwnership.show && fromEl.hasAttribute('hidden')) {
903
+ emitOwnershipConflict(
904
+ debug,
905
+ toEl,
906
+ 'hidden',
907
+ null,
908
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "hidden" attribute and client data-w-show manage visibility.`
909
+ );
910
+ }
911
+ if (effectiveOwnership.disabled && fromEl.hasAttribute('disabled')) {
912
+ emitOwnershipConflict(
913
+ debug,
914
+ toEl,
915
+ 'disabled',
916
+ null,
917
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "disabled" attribute and client data-w-disabled manage disabled state.`
918
+ );
919
+ }
920
+ if (effectiveOwnership.model && fromEl.hasAttribute('value') && fromEl.getAttribute('value') !== '') {
921
+ emitOwnershipConflict(
922
+ debug,
923
+ toEl,
924
+ 'value',
925
+ null,
926
+ `Ownership conflict on <${(toEl.tagName || 'el').toLowerCase()}>: both server "value" attribute and client data-w-model manage input value.`
927
+ );
928
+ }
929
+
767
930
  // Remove attributes no longer present on server HTML, except client-managed runtime state
768
931
  for (let i = 0; i < toAttrs.length; i++) {
769
932
  const attrName = toAttrs[i].name;
770
- if (ownership.disabled && attrName === 'disabled') continue;
771
- if (ownership.show && attrName === 'hidden') continue;
933
+ if (attrName === 'class') continue; // Handled by syncClassAttribute
934
+ if (effectiveOwnership.disabled && (attrName === 'disabled' || attrName === 'aria-disabled')) continue;
935
+ if (effectiveOwnership.show && attrName === 'hidden') continue;
772
936
  if (attrName.startsWith('data-w-bound-')) continue;
773
937
  if (
774
938
  attrName === 'data-w-fragment-input' ||
@@ -782,15 +946,34 @@ function syncElementAttributes(toEl, fromEl, ownership) {
782
946
  }
783
947
  }
784
948
 
949
+ // If `data-w-show` was removed and server does not set `style`, clear inline `display: none`
950
+ if (ownership.show && !effectiveOwnership.show && !fromEl.hasAttribute('style') && toEl.style && toEl.style.display === 'none') {
951
+ toEl.style.display = '';
952
+ }
953
+
785
954
  // Set updated attributes from server HTML
786
955
  for (let i = 0; i < fromAttrs.length; i++) {
787
956
  const attr = fromAttrs[i];
788
- if (ownership.disabled && attr.name === 'disabled') continue;
789
- if (ownership.show && attr.name === 'hidden') continue;
957
+ if (attr.name === 'class') continue; // Handled by syncClassAttribute
958
+ if (effectiveOwnership.disabled && (attr.name === 'disabled' || attr.name === 'aria-disabled')) continue;
959
+ if (effectiveOwnership.show && attr.name === 'hidden') continue;
960
+ if (attr.name === 'action' && toEl.tagName === 'FORM' && (toEl.__wCustomAction || insideDirtyForm)) {
961
+ continue;
962
+ }
790
963
  if (toEl.getAttribute(attr.name) !== attr.value) {
791
964
  toEl.setAttribute(attr.name, attr.value);
792
965
  }
793
966
  }
967
+
968
+ // Sync `class` preserving client-owned class tokens
969
+ syncClassAttribute(toEl, fromEl, debug);
970
+
971
+ // Restore `style.display = 'none'` if server `style` overwrote it while `data-w-show` hides the element
972
+ if (wasHiddenByShow && toEl.style && toEl.style.display !== 'none') {
973
+ toEl.style.display = 'none';
974
+ }
975
+
976
+ return effectiveOwnership;
794
977
  }
795
978
 
796
979
  /**
@@ -843,11 +1026,19 @@ function syncFormControlValue(toEl, fromEl, ownership, preserveDraft) {
843
1026
  function morphDomTrees(toEl, fromEl, ctx = {}) {
844
1027
  if (!toEl || !fromEl) return toEl;
845
1028
 
846
- const ownership = getClientOwnership(toEl);
1029
+ const rawOwnership = getClientOwnership(toEl);
1030
+ const ignoreMode = getIgnoreMode(toEl);
847
1031
  const isForm = toEl.tagName === 'FORM';
848
1032
  const insideDirtyForm = Boolean(ctx.insideDirtyForm || (isForm && isFormDirtyOrPreserved(toEl)));
849
1033
 
850
- syncElementAttributes(toEl, fromEl, ownership);
1034
+ const ownership =
1035
+ ignoreMode === 'self' || ignoreMode === 'all'
1036
+ ? rawOwnership
1037
+ : syncElementAttributes(toEl, fromEl, rawOwnership, insideDirtyForm, ctx.debug);
1038
+
1039
+ if (ignoreMode === 'children' || ignoreMode === 'all') {
1040
+ return toEl;
1041
+ }
851
1042
 
852
1043
  if (toEl.tagName === 'INPUT' || toEl.tagName === 'TEXTAREA' || toEl.tagName === 'SELECT') {
853
1044
  syncFormControlValue(toEl, fromEl, ownership, insideDirtyForm);
@@ -896,7 +1087,7 @@ function morphDomTrees(toEl, fromEl, ctx = {}) {
896
1087
  }
897
1088
  }
898
1089
 
899
- if (!match) {
1090
+ if (!match && !key) {
900
1091
  // Look ahead for a compatible unkeyed element with the same tagName and controller attribute
901
1092
  for (let j = unkeyedCursor; j < existingChildren.length; j++) {
902
1093
  const cand = existingChildren[j];
@@ -1063,7 +1254,7 @@ function createDatastarAdapter(options = {}) {
1063
1254
  * - Single binding per element/attribute per instance (`WeakMap` guard)
1064
1255
  * - All effects & listeners returned in a single cleanup function
1065
1256
  */
1066
- const boundElementsByInstance = new Map(); // instanceId -> WeakSet<Element>
1257
+ const boundElementsByInstance = new Map(); // instanceId -> Map<Element, Array<() => void>>
1067
1258
 
1068
1259
  function isWithinControllerScope(rootEl, candidateEl) {
1069
1260
  if (candidateEl === rootEl) return true;
@@ -1152,6 +1343,167 @@ function createDatastarAdapter(options = {}) {
1152
1343
  }
1153
1344
  }
1154
1345
 
1346
+ /**
1347
+ * Parse `{ 'is-active': expr1, isOpen: expr2 }` into `Array<{ classes: string[], expr: string }>`.
1348
+ */
1349
+ function parseClassMapExpression(rawExpr) {
1350
+ const trimmed = (rawExpr || '').trim();
1351
+ if (!trimmed) return [];
1352
+ const inner = trimmed.startsWith('{') && trimmed.endsWith('}')
1353
+ ? trimmed.slice(1, -1).trim()
1354
+ : trimmed;
1355
+ if (!inner) return [];
1356
+
1357
+ const entries = [];
1358
+ let current = '';
1359
+ let parenDepth = 0;
1360
+ let bracketDepth = 0;
1361
+ let inQuote = null;
1362
+
1363
+ for (let i = 0; i < inner.length; i++) {
1364
+ const ch = inner[i];
1365
+ if (inQuote) {
1366
+ current += ch;
1367
+ if (ch === inQuote && inner[i - 1] !== '\\') inQuote = null;
1368
+ continue;
1369
+ }
1370
+ if (ch === '"' || ch === "'") {
1371
+ inQuote = ch;
1372
+ current += ch;
1373
+ continue;
1374
+ }
1375
+ if (ch === '(') parenDepth++;
1376
+ else if (ch === ')' && parenDepth > 0) parenDepth--;
1377
+ else if (ch === '[') bracketDepth++;
1378
+ else if (ch === ']' && bracketDepth > 0) bracketDepth--;
1379
+
1380
+ if (ch === ',' && parenDepth === 0 && bracketDepth === 0) {
1381
+ if (current.trim()) entries.push(current.trim());
1382
+ current = '';
1383
+ } else {
1384
+ current += ch;
1385
+ }
1386
+ }
1387
+ if (current.trim()) entries.push(current.trim());
1388
+
1389
+ const result = [];
1390
+ for (const entry of entries) {
1391
+ let colonIdx = -1;
1392
+ let q = null;
1393
+ for (let i = 0; i < entry.length; i++) {
1394
+ const ch = entry[i];
1395
+ if (q) {
1396
+ if (ch === q && entry[i - 1] !== '\\') q = null;
1397
+ continue;
1398
+ }
1399
+ if (ch === '"' || ch === "'") {
1400
+ q = ch;
1401
+ continue;
1402
+ }
1403
+ if (ch === ':') {
1404
+ colonIdx = i;
1405
+ break;
1406
+ }
1407
+ }
1408
+ if (colonIdx <= 0) continue;
1409
+ let rawKey = entry.slice(0, colonIdx).trim();
1410
+ const expr = entry.slice(colonIdx + 1).trim();
1411
+ if ((rawKey.startsWith("'") && rawKey.endsWith("'")) || (rawKey.startsWith('"') && rawKey.endsWith('"'))) {
1412
+ rawKey = rawKey.slice(1, -1);
1413
+ }
1414
+ const classes = rawKey.split(/\s+/).filter(Boolean);
1415
+ if (classes.length > 0 && expr) {
1416
+ result.push({ classes, expr });
1417
+ }
1418
+ }
1419
+ return result;
1420
+ }
1421
+
1422
+ function parseEventScheduleModifiers(modifiers) {
1423
+ let mode = null;
1424
+ let waitMs = 250;
1425
+ let leading = null;
1426
+ let trailing = null;
1427
+
1428
+ const list = Array.from(modifiers);
1429
+ for (let i = 0; i < list.length; i++) {
1430
+ const mod = list[i];
1431
+ if (mod === 'debounce' || mod === 'throttle') {
1432
+ mode = mod;
1433
+ const next = list[i + 1];
1434
+ if (next && /^\d+(?:ms|s)?$/i.test(next)) {
1435
+ waitMs = parseDelayMs(next, 250);
1436
+ i++;
1437
+ }
1438
+ } else if (mod.startsWith('debounce-')) {
1439
+ mode = 'debounce';
1440
+ waitMs = parseDelayMs(mod.slice('debounce-'.length), 250);
1441
+ } else if (mod.startsWith('throttle-')) {
1442
+ mode = 'throttle';
1443
+ waitMs = parseDelayMs(mod.slice('throttle-'.length), 250);
1444
+ } else if (mod === 'leading') {
1445
+ leading = true;
1446
+ } else if (mod === 'trailing') {
1447
+ trailing = true;
1448
+ }
1449
+ }
1450
+
1451
+ if (!mode) return null;
1452
+ if (mode === 'debounce') {
1453
+ return {
1454
+ mode: 'debounce',
1455
+ waitMs,
1456
+ leading: leading === true,
1457
+ trailing: trailing !== false,
1458
+ };
1459
+ }
1460
+ return {
1461
+ mode: 'throttle',
1462
+ waitMs,
1463
+ leading: leading !== false,
1464
+ trailing: trailing !== false,
1465
+ };
1466
+ }
1467
+
1468
+ function getElementBindingSignature(el) {
1469
+ if (!el || !el.attributes) return '';
1470
+ const parts = [];
1471
+ for (let i = 0; i < el.attributes.length; i++) {
1472
+ const n = el.attributes[i].name;
1473
+ if (
1474
+ n === 'data-w-text' ||
1475
+ n === 'data-w-show' ||
1476
+ n === 'data-w-disabled' ||
1477
+ n === 'data-w-model' ||
1478
+ n === 'data-w-class' ||
1479
+ n.startsWith('data-w-class:') ||
1480
+ n === 'data-w-loading' ||
1481
+ n === 'data-w-loading-hide' ||
1482
+ n === 'data-w-loading-delay' ||
1483
+ n === 'data-w-loading-class' ||
1484
+ n === 'data-w-on' ||
1485
+ n.startsWith('data-w-on:') ||
1486
+ n === 'data-w-click'
1487
+ ) {
1488
+ parts.push(`${n}=${el.attributes[i].value}`);
1489
+ }
1490
+ }
1491
+ parts.sort();
1492
+ return parts.join('\n');
1493
+ }
1494
+
1495
+ function setOwnedClassToken(el, token, active) {
1496
+ if (!el) return;
1497
+ if (!el.__wOwnedClassState) {
1498
+ el.__wOwnedClassState = new Map();
1499
+ }
1500
+ el.__wOwnedClassState.set(token, Boolean(active));
1501
+ if (el.classList) {
1502
+ if (active) el.classList.add(token);
1503
+ else el.classList.remove(token);
1504
+ }
1505
+ }
1506
+
1155
1507
  /**
1156
1508
  * Bind V1 declarative attributes inside `rootEl` for a controller instance.
1157
1509
  */
@@ -1164,30 +1516,63 @@ function createDatastarAdapter(options = {}) {
1164
1516
  isLoadingTargetActive,
1165
1517
  }) {
1166
1518
  if (!boundElementsByInstance.has(instanceId)) {
1167
- boundElementsByInstance.set(instanceId, new WeakSet());
1519
+ boundElementsByInstance.set(instanceId, new Map());
1520
+ }
1521
+ const elementDisposersMap = boundElementsByInstance.get(instanceId);
1522
+
1523
+ // Prune any previously bound elements that were detached/replaced by fragment patches
1524
+ for (const [prevEl, prevDisposers] of Array.from(elementDisposersMap.entries())) {
1525
+ const detached =
1526
+ !prevEl ||
1527
+ prevEl.isConnected === false ||
1528
+ (rootEl && typeof rootEl.contains === 'function' && !rootEl.contains(prevEl));
1529
+ if (detached) {
1530
+ for (let d = 0; d < prevDisposers.length; d++) {
1531
+ try {
1532
+ prevDisposers[d]();
1533
+ } catch (_e) {}
1534
+ }
1535
+ elementDisposersMap.delete(prevEl);
1536
+ }
1168
1537
  }
1169
- const boundSet = boundElementsByInstance.get(instanceId);
1170
- const disposers = [];
1171
1538
 
1172
1539
  const elements = collectScopedElements(rootEl);
1173
1540
 
1174
1541
  for (let i = 0; i < elements.length; i++) {
1175
1542
  const el = elements[i];
1176
- if (boundSet.has(el)) continue;
1177
- boundSet.add(el);
1543
+ const sig = getElementBindingSignature(el);
1544
+ if (elementDisposersMap.has(el)) {
1545
+ if (el.__wBindingSig === sig) continue;
1546
+ // Bindings on this preserved element changed (e.g. server added/removed data-w-show/class):
1547
+ // dispose old bindings and re-bind cleanly.
1548
+ const oldDisposers = elementDisposersMap.get(el);
1549
+ for (let d = 0; d < oldDisposers.length; d++) {
1550
+ try {
1551
+ oldDisposers[d]();
1552
+ } catch (_e) {}
1553
+ }
1554
+ elementDisposersMap.delete(el);
1555
+ if (el.__wOwnedClassState) {
1556
+ el.__wOwnedClassState.clear();
1557
+ delete el.__wOwnedClassState;
1558
+ }
1559
+ }
1560
+ el.__wBindingSig = sig;
1561
+ const elDisposers = [];
1562
+ elementDisposersMap.set(el, elDisposers);
1178
1563
 
1179
1564
  // 1. data-w-text
1180
1565
  if (el.hasAttribute('data-w-text')) {
1181
1566
  const expr = el.getAttribute('data-w-text');
1182
1567
  const stop = engine.effect(() => {
1183
- if (!isConnected()) return;
1568
+ if (!isConnected() || el.isConnected === false) return;
1184
1569
  const val = evaluateStateExpression(state, expr);
1185
1570
  const text = val === null || val === undefined ? '' : String(val);
1186
1571
  if (el.textContent !== text) {
1187
1572
  el.textContent = text;
1188
1573
  }
1189
1574
  });
1190
- disposers.push(stop);
1575
+ elDisposers.push(stop);
1191
1576
  }
1192
1577
 
1193
1578
  // 2. data-w-show
@@ -1195,43 +1580,119 @@ function createDatastarAdapter(options = {}) {
1195
1580
  const expr = el.getAttribute('data-w-show');
1196
1581
  const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1197
1582
  const stop = engine.effect(() => {
1198
- if (!isConnected()) return;
1583
+ if (!isConnected() || el.isConnected === false) return;
1199
1584
  toggleElementVisibility(el, Boolean(evaluateStateExpression(state, expr)), initialDisplay);
1200
1585
  });
1201
- disposers.push(stop);
1586
+ elDisposers.push(stop);
1202
1587
  }
1203
1588
 
1204
- // 2b. data-w-loading & data-w-loading-hide (declarative action/task busy indicators)
1589
+ // 2a. data-w-class & data-w-class:<token>
1590
+ if (el.hasAttribute('data-w-class')) {
1591
+ const rawClassExpr = el.getAttribute('data-w-class') || '';
1592
+ const classEntries = parseClassMapExpression(rawClassExpr);
1593
+ for (let c = 0; c < classEntries.length; c++) {
1594
+ const entry = classEntries[c];
1595
+ const stop = engine.effect(() => {
1596
+ if (!isConnected() || el.isConnected === false) return;
1597
+ const active = Boolean(evaluateStateExpression(state, entry.expr));
1598
+ for (let k = 0; k < entry.classes.length; k++) {
1599
+ setOwnedClassToken(el, entry.classes[k], active);
1600
+ }
1601
+ });
1602
+ elDisposers.push(stop);
1603
+ }
1604
+ }
1605
+ if (el.attributes) {
1606
+ for (let a = 0; a < el.attributes.length; a++) {
1607
+ const attr = el.attributes[a];
1608
+ if (attr.name.startsWith('data-w-class:')) {
1609
+ const token = attr.name.slice('data-w-class:'.length).trim();
1610
+ const expr = (attr.value || '').trim();
1611
+ if (token && expr) {
1612
+ const stop = engine.effect(() => {
1613
+ if (!isConnected() || el.isConnected === false) return;
1614
+ setOwnedClassToken(el, token, Boolean(evaluateStateExpression(state, expr)));
1615
+ });
1616
+ elDisposers.push(stop);
1617
+ }
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ // 2b. data-w-loading & data-w-loading-hide (with optional data-w-loading-delay)
1205
1623
  if (el.hasAttribute('data-w-loading') || el.hasAttribute('data-w-loading-hide')) {
1206
1624
  const isHide = el.hasAttribute('data-w-loading-hide');
1207
1625
  const rawSpec = el.getAttribute(isHide ? 'data-w-loading-hide' : 'data-w-loading') || '';
1208
1626
  const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1627
+ const delayMs = parseDelayMs(el.getAttribute('data-w-loading-delay'), 0);
1209
1628
  const loadingClasses = (el.getAttribute('data-w-loading-class') || '')
1210
1629
  .trim()
1211
1630
  .split(/\s+/)
1212
1631
  .filter(Boolean);
1632
+
1633
+ let delayTimer = null;
1634
+ let generation = 0;
1635
+ let visuallyActive = false;
1636
+
1637
+ const applyVisualLoading = (active) => {
1638
+ visuallyActive = active;
1639
+ toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1640
+ if (loadingClasses.length > 0 && el.classList) {
1641
+ for (let c = 0; c < loadingClasses.length; c++) {
1642
+ setOwnedClassToken(el, loadingClasses[c], active);
1643
+ }
1644
+ }
1645
+ };
1646
+
1647
+ // Initialize non-loading state synchronously when delay > 0
1648
+ if (delayMs > 0) {
1649
+ applyVisualLoading(false);
1650
+ }
1651
+
1213
1652
  const stop = engine.effect(() => {
1214
- if (!isConnected()) return;
1653
+ if (!isConnected() || el.isConnected === false) return;
1215
1654
  const active =
1216
1655
  typeof isLoadingTargetActive === 'function'
1217
1656
  ? Boolean(isLoadingTargetActive(rawSpec))
1218
1657
  : Boolean(evaluateStateExpression(state, rawSpec || '$busy'));
1219
- toggleElementVisibility(el, isHide ? !active : active, initialDisplay);
1220
- if (loadingClasses.length > 0 && el.classList) {
1221
- for (let c = 0; c < loadingClasses.length; c++) {
1222
- if (active) el.classList.add(loadingClasses[c]);
1223
- else el.classList.remove(loadingClasses[c]);
1658
+
1659
+ if (delayMs <= 0) {
1660
+ applyVisualLoading(active);
1661
+ return;
1662
+ }
1663
+
1664
+ if (active) {
1665
+ if (visuallyActive || delayTimer) return;
1666
+ const gen = ++generation;
1667
+ delayTimer = setTimeout(() => {
1668
+ delayTimer = null;
1669
+ if (gen !== generation || !isConnected() || el.isConnected === false) return;
1670
+ applyVisualLoading(true);
1671
+ }, delayMs);
1672
+ } else {
1673
+ generation++;
1674
+ if (delayTimer) {
1675
+ clearTimeout(delayTimer);
1676
+ delayTimer = null;
1224
1677
  }
1678
+ applyVisualLoading(false);
1679
+ }
1680
+ });
1681
+ elDisposers.push(() => {
1682
+ generation++;
1683
+ if (delayTimer) {
1684
+ clearTimeout(delayTimer);
1685
+ delayTimer = null;
1225
1686
  }
1687
+ stop();
1226
1688
  });
1227
- disposers.push(stop);
1228
1689
  }
1229
1690
 
1230
1691
  // 3. data-w-disabled
1231
1692
  if (el.hasAttribute('data-w-disabled')) {
1232
1693
  const expr = el.getAttribute('data-w-disabled');
1233
1694
  const stop = engine.effect(() => {
1234
- if (!isConnected()) return;
1695
+ if (!isConnected() || el.isConnected === false) return;
1235
1696
  const disabled = Boolean(evaluateStateExpression(state, expr));
1236
1697
  if ('disabled' in el) {
1237
1698
  el.disabled = disabled;
@@ -1244,7 +1705,7 @@ function createDatastarAdapter(options = {}) {
1244
1705
  el.removeAttribute('aria-disabled');
1245
1706
  }
1246
1707
  });
1247
- disposers.push(stop);
1708
+ elDisposers.push(stop);
1248
1709
  }
1249
1710
 
1250
1711
  // 4. data-w-model (two-way binding between input/select/textarea and controller state)
@@ -1286,7 +1747,7 @@ function createDatastarAdapter(options = {}) {
1286
1747
  };
1287
1748
  el.addEventListener('compositionstart', onCompositionStart);
1288
1749
  el.addEventListener('compositionend', onCompositionEnd);
1289
- disposers.push(() => {
1750
+ elDisposers.push(() => {
1290
1751
  try {
1291
1752
  el.removeEventListener('compositionstart', onCompositionStart);
1292
1753
  el.removeEventListener('compositionend', onCompositionEnd);
@@ -1295,7 +1756,7 @@ function createDatastarAdapter(options = {}) {
1295
1756
 
1296
1757
  // State -> DOM effect (synchronizes changes in state to the DOM control)
1297
1758
  const stopModelEffect = engine.effect(() => {
1298
- if (!isConnected() || isComposing) return;
1759
+ if (!isConnected() || el.isConnected === false || isComposing) return;
1299
1760
  const currentVal = getByPath(state, modelPath);
1300
1761
 
1301
1762
  if (type === 'checkbox') {
@@ -1319,7 +1780,7 @@ function createDatastarAdapter(options = {}) {
1319
1780
  }
1320
1781
  }
1321
1782
  });
1322
- disposers.push(stopModelEffect);
1783
+ elDisposers.push(stopModelEffect);
1323
1784
 
1324
1785
  // DOM -> State listener (synchronizes user interactions to state)
1325
1786
  const evtType =
@@ -1328,7 +1789,7 @@ function createDatastarAdapter(options = {}) {
1328
1789
  : 'input';
1329
1790
 
1330
1791
  const onModelInput = () => {
1331
- if (!isConnected() || isComposing) return;
1792
+ if (!isConnected() || el.isConnected === false || isComposing) return;
1332
1793
 
1333
1794
  if (type === 'checkbox') {
1334
1795
  const cur = getByPath(state, modelPath);
@@ -1360,31 +1821,172 @@ function createDatastarAdapter(options = {}) {
1360
1821
  };
1361
1822
 
1362
1823
  el.addEventListener(evtType, onModelInput);
1363
- disposers.push(() => el.removeEventListener(evtType, onModelInput));
1824
+ elDisposers.push(() => el.removeEventListener(evtType, onModelInput));
1364
1825
  }
1365
1826
  }
1366
1827
 
1367
1828
  // 5. Event bindings (data-w-on:<event>, data-w-on, data-w-click)
1368
1829
  const eventBindings = extractEventBindings(el);
1369
- for (const binding of eventBindings) {
1370
- const listener = (event) => {
1371
- if (!isConnected()) return;
1372
- if (binding.modifiers.has('prevent')) event.preventDefault();
1373
- if (binding.modifiers.has('stop')) event.stopPropagation();
1374
- invokeAction(binding.actionName, event, el);
1375
- };
1376
- el.addEventListener(binding.eventName, listener);
1377
- disposers.push(() => el.removeEventListener(binding.eventName, listener));
1830
+ if (eventBindings.length > 0) {
1831
+ let isEventComposing = false;
1832
+ const hasScheduled = eventBindings.some((b) => parseEventScheduleModifiers(b.modifiers) !== null);
1833
+ if (hasScheduled) {
1834
+ const onCompStart = () => {
1835
+ isEventComposing = true;
1836
+ };
1837
+ const onCompEnd = () => {
1838
+ isEventComposing = false;
1839
+ };
1840
+ el.addEventListener('compositionstart', onCompStart);
1841
+ el.addEventListener('compositionend', onCompEnd);
1842
+ elDisposers.push(() => {
1843
+ try {
1844
+ el.removeEventListener('compositionstart', onCompStart);
1845
+ el.removeEventListener('compositionend', onCompEnd);
1846
+ } catch (_e) {}
1847
+ });
1848
+ }
1849
+
1850
+ const parentForm = typeof el.closest === 'function' ? el.closest('form') : el.form || null;
1851
+
1852
+ for (const binding of eventBindings) {
1853
+ const schedule = parseEventScheduleModifiers(binding.modifiers);
1854
+ if (!schedule) {
1855
+ const listener = (event) => {
1856
+ if (!isConnected() || el.isConnected === false) return;
1857
+ if (binding.modifiers.has('prevent')) event.preventDefault();
1858
+ if (binding.modifiers.has('stop')) event.stopPropagation();
1859
+ invokeAction(binding.actionName, event, el);
1860
+ };
1861
+ el.addEventListener(binding.eventName, listener);
1862
+ elDisposers.push(() => el.removeEventListener(binding.eventName, listener));
1863
+ continue;
1864
+ }
1865
+
1866
+ let timer = null;
1867
+ let lastExecTime = 0;
1868
+ let pendingEvent = null;
1869
+ let generation = 0;
1870
+
1871
+ const cancelPending = () => {
1872
+ generation++;
1873
+ if (timer) {
1874
+ clearTimeout(timer);
1875
+ timer = null;
1876
+ }
1877
+ pendingEvent = null;
1878
+ };
1879
+
1880
+ const fireScheduled = (capturedEvent) => {
1881
+ if (!isConnected() || el.isConnected === false) return;
1882
+ invokeAction(binding.actionName, capturedEvent, el);
1883
+ };
1884
+
1885
+ const listener = (event) => {
1886
+ if (!isConnected() || el.isConnected === false) return;
1887
+ if (binding.modifiers.has('prevent')) event.preventDefault();
1888
+ if (binding.modifiers.has('stop')) event.stopPropagation();
1889
+ if (
1890
+ (event.isComposing || isEventComposing) &&
1891
+ (binding.eventName === 'input' || binding.eventName === 'keydown' || binding.eventName === 'keyup')
1892
+ ) {
1893
+ return;
1894
+ }
1895
+
1896
+ // Capture a safe event reference (never rely on event.currentTarget after async delay)
1897
+ const capturedEvent = event;
1898
+ const gen = generation;
1899
+
1900
+ if (schedule.mode === 'debounce') {
1901
+ const callNow = schedule.leading && !timer;
1902
+ if (timer) clearTimeout(timer);
1903
+ pendingEvent = capturedEvent;
1904
+ timer = setTimeout(() => {
1905
+ timer = null;
1906
+ if (gen !== generation) return;
1907
+ const evtToPass = pendingEvent;
1908
+ pendingEvent = null;
1909
+ if (schedule.trailing && evtToPass) {
1910
+ fireScheduled(evtToPass);
1911
+ }
1912
+ }, schedule.waitMs);
1913
+ if (callNow) {
1914
+ pendingEvent = null;
1915
+ fireScheduled(capturedEvent);
1916
+ }
1917
+ } else {
1918
+ // Throttle: default leading + trailing
1919
+ const now = Date.now();
1920
+ const remaining = schedule.waitMs - (now - lastExecTime);
1921
+ if (remaining <= 0 || remaining > schedule.waitMs) {
1922
+ if (timer) {
1923
+ clearTimeout(timer);
1924
+ timer = null;
1925
+ }
1926
+ pendingEvent = null;
1927
+ if (schedule.leading) {
1928
+ lastExecTime = now;
1929
+ fireScheduled(capturedEvent);
1930
+ } else if (schedule.trailing) {
1931
+ pendingEvent = capturedEvent;
1932
+ timer = setTimeout(() => {
1933
+ timer = null;
1934
+ if (gen !== generation) return;
1935
+ lastExecTime = Date.now();
1936
+ const evtToPass = pendingEvent;
1937
+ pendingEvent = null;
1938
+ if (evtToPass) fireScheduled(evtToPass);
1939
+ }, schedule.waitMs);
1940
+ }
1941
+ } else if (schedule.trailing) {
1942
+ pendingEvent = capturedEvent;
1943
+ if (!timer) {
1944
+ timer = setTimeout(() => {
1945
+ timer = null;
1946
+ if (gen !== generation) return;
1947
+ lastExecTime = schedule.leading ? Date.now() : 0;
1948
+ const evtToPass = pendingEvent;
1949
+ pendingEvent = null;
1950
+ if (evtToPass) fireScheduled(evtToPass);
1951
+ }, remaining);
1952
+ }
1953
+ }
1954
+ }
1955
+ };
1956
+
1957
+ el.addEventListener(binding.eventName, listener);
1958
+ elDisposers.push(() => {
1959
+ cancelPending();
1960
+ el.removeEventListener(binding.eventName, listener);
1961
+ });
1962
+
1963
+ if (parentForm && typeof parentForm.addEventListener === 'function') {
1964
+ const onFormReset = () => cancelPending();
1965
+ parentForm.addEventListener('reset', onFormReset);
1966
+ elDisposers.push(() => {
1967
+ try {
1968
+ parentForm.removeEventListener('reset', onFormReset);
1969
+ } catch (_e) {}
1970
+ });
1971
+ }
1972
+ }
1378
1973
  }
1379
1974
  }
1380
1975
 
1381
1976
  return function unbindInstanceDom() {
1382
- for (let i = 0; i < disposers.length; i++) {
1383
- try {
1384
- disposers[i]();
1385
- } catch (_err) {
1386
- // Ignore individual unbind errors
1977
+ const map = boundElementsByInstance.get(instanceId);
1978
+ if (map) {
1979
+ for (const elDisposers of map.values()) {
1980
+ for (let i = 0; i < elDisposers.length; i++) {
1981
+ try {
1982
+ elDisposers[i]();
1983
+ } catch (_err) {
1984
+ // Ignore individual unbind errors
1985
+ }
1986
+ }
1387
1987
  }
1988
+ map.clear();
1989
+ boundElementsByInstance.delete(instanceId);
1388
1990
  }
1389
1991
  };
1390
1992
  }
@@ -1394,14 +1996,54 @@ function createDatastarAdapter(options = {}) {
1394
1996
  */
1395
1997
  function patchFragmentElement(targetEl, htmlString, lifecycleCallbacks = {}) {
1396
1998
  if (!targetEl || typeof htmlString !== 'string') return targetEl;
1397
- const parsed = parseHtmlFragment(htmlString, targetEl.ownerDocument);
1999
+ const doc = targetEl.ownerDocument || (typeof document !== 'undefined' ? document : null);
2000
+ const parsed = parseHtmlFragment(htmlString, doc);
1398
2001
  if (!parsed) return targetEl;
1399
2002
 
2003
+ // Capture active focus & text selection if inside targetEl before morphing
2004
+ let focusedEl = null;
2005
+ let selStart = null;
2006
+ let selEnd = null;
2007
+ if (
2008
+ doc &&
2009
+ doc.activeElement &&
2010
+ typeof targetEl.contains === 'function' &&
2011
+ targetEl.contains(doc.activeElement)
2012
+ ) {
2013
+ focusedEl = doc.activeElement;
2014
+ try {
2015
+ if (typeof focusedEl.selectionStart === 'number') selStart = focusedEl.selectionStart;
2016
+ if (typeof focusedEl.selectionEnd === 'number') selEnd = focusedEl.selectionEnd;
2017
+ } catch (_e) {}
2018
+ }
2019
+
1400
2020
  const morphed = morphDomTrees(targetEl, parsed, {
1401
2021
  onBeforeNodeRemove: lifecycleCallbacks.onBeforeNodeRemove,
1402
2022
  onNodeAdded: lifecycleCallbacks.onNodeAdded,
2023
+ debug: options.debug || null,
1403
2024
  });
1404
2025
 
2026
+ // Restore focus & selection range if the focused element was preserved inside targetEl
2027
+ if (
2028
+ focusedEl &&
2029
+ focusedEl.isConnected !== false &&
2030
+ typeof targetEl.contains === 'function' &&
2031
+ targetEl.contains(focusedEl)
2032
+ ) {
2033
+ try {
2034
+ if (doc && doc.activeElement !== focusedEl && typeof focusedEl.focus === 'function') {
2035
+ focusedEl.focus();
2036
+ }
2037
+ if (
2038
+ typeof selStart === 'number' &&
2039
+ typeof selEnd === 'number' &&
2040
+ typeof focusedEl.setSelectionRange === 'function'
2041
+ ) {
2042
+ focusedEl.setSelectionRange(selStart, selEnd);
2043
+ }
2044
+ } catch (_e) {}
2045
+ }
2046
+
1405
2047
  // If external @starfederation/datastar is active, notify apply() on updated subtree
1406
2048
  if (externalDatastar && typeof externalDatastar.apply === 'function') {
1407
2049
  try {
@@ -1432,6 +2074,14 @@ function createDatastarAdapter(options = {}) {
1432
2074
  setByPath,
1433
2075
  isWithinControllerScope,
1434
2076
  collectScopedElements,
2077
+ getBoundInstanceCount: () => boundElementsByInstance.size,
2078
+ getBoundElementCount: () => {
2079
+ let total = 0;
2080
+ for (const m of boundElementsByInstance.values()) {
2081
+ total += m.size;
2082
+ }
2083
+ return total;
2084
+ },
1435
2085
  };
1436
2086
  }
1437
2087