webspresso 0.0.111 → 0.0.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -760,7 +776,7 @@ function getMorphNodeKey(node) {
760
776
  * Sync attributes from `fromEl` onto `toEl` while respecting client-owned properties
761
777
  * and preserving active focus / form draft states.
762
778
  */
763
- function syncElementAttributes(toEl, fromEl, ownership) {
779
+ function syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm = false) {
764
780
  const fromAttrs = fromEl.attributes;
765
781
  const toAttrs = Array.from(toEl.attributes);
766
782
 
@@ -787,6 +803,9 @@ function syncElementAttributes(toEl, fromEl, ownership) {
787
803
  const attr = fromAttrs[i];
788
804
  if (ownership.disabled && attr.name === 'disabled') continue;
789
805
  if (ownership.show && attr.name === 'hidden') continue;
806
+ if (attr.name === 'action' && toEl.tagName === 'FORM' && (toEl.__wCustomAction || insideDirtyForm)) {
807
+ continue;
808
+ }
790
809
  if (toEl.getAttribute(attr.name) !== attr.value) {
791
810
  toEl.setAttribute(attr.name, attr.value);
792
811
  }
@@ -847,7 +866,7 @@ function morphDomTrees(toEl, fromEl, ctx = {}) {
847
866
  const isForm = toEl.tagName === 'FORM';
848
867
  const insideDirtyForm = Boolean(ctx.insideDirtyForm || (isForm && isFormDirtyOrPreserved(toEl)));
849
868
 
850
- syncElementAttributes(toEl, fromEl, ownership);
869
+ syncElementAttributes(toEl, fromEl, ownership, insideDirtyForm);
851
870
 
852
871
  if (toEl.tagName === 'INPUT' || toEl.tagName === 'TEXTAREA' || toEl.tagName === 'SELECT') {
853
872
  syncFormControlValue(toEl, fromEl, ownership, insideDirtyForm);
@@ -896,7 +915,7 @@ function morphDomTrees(toEl, fromEl, ctx = {}) {
896
915
  }
897
916
  }
898
917
 
899
- if (!match) {
918
+ if (!match && !key) {
900
919
  // Look ahead for a compatible unkeyed element with the same tagName and controller attribute
901
920
  for (let j = unkeyedCursor; j < existingChildren.length; j++) {
902
921
  const cand = existingChildren[j];
@@ -1063,7 +1082,7 @@ function createDatastarAdapter(options = {}) {
1063
1082
  * - Single binding per element/attribute per instance (`WeakMap` guard)
1064
1083
  * - All effects & listeners returned in a single cleanup function
1065
1084
  */
1066
- const boundElementsByInstance = new Map(); // instanceId -> WeakSet<Element>
1085
+ const boundElementsByInstance = new Map(); // instanceId -> Map<Element, Array<() => void>>
1067
1086
 
1068
1087
  function isWithinControllerScope(rootEl, candidateEl) {
1069
1088
  if (candidateEl === rootEl) return true;
@@ -1164,30 +1183,46 @@ function createDatastarAdapter(options = {}) {
1164
1183
  isLoadingTargetActive,
1165
1184
  }) {
1166
1185
  if (!boundElementsByInstance.has(instanceId)) {
1167
- boundElementsByInstance.set(instanceId, new WeakSet());
1186
+ boundElementsByInstance.set(instanceId, new Map());
1187
+ }
1188
+ const elementDisposersMap = boundElementsByInstance.get(instanceId);
1189
+
1190
+ // Prune any previously bound elements that were detached/replaced by fragment patches
1191
+ for (const [prevEl, prevDisposers] of Array.from(elementDisposersMap.entries())) {
1192
+ const detached =
1193
+ !prevEl ||
1194
+ prevEl.isConnected === false ||
1195
+ (rootEl && typeof rootEl.contains === 'function' && !rootEl.contains(prevEl));
1196
+ if (detached) {
1197
+ for (let d = 0; d < prevDisposers.length; d++) {
1198
+ try {
1199
+ prevDisposers[d]();
1200
+ } catch (_e) {}
1201
+ }
1202
+ elementDisposersMap.delete(prevEl);
1203
+ }
1168
1204
  }
1169
- const boundSet = boundElementsByInstance.get(instanceId);
1170
- const disposers = [];
1171
1205
 
1172
1206
  const elements = collectScopedElements(rootEl);
1173
1207
 
1174
1208
  for (let i = 0; i < elements.length; i++) {
1175
1209
  const el = elements[i];
1176
- if (boundSet.has(el)) continue;
1177
- boundSet.add(el);
1210
+ if (elementDisposersMap.has(el)) continue;
1211
+ const elDisposers = [];
1212
+ elementDisposersMap.set(el, elDisposers);
1178
1213
 
1179
1214
  // 1. data-w-text
1180
1215
  if (el.hasAttribute('data-w-text')) {
1181
1216
  const expr = el.getAttribute('data-w-text');
1182
1217
  const stop = engine.effect(() => {
1183
- if (!isConnected()) return;
1218
+ if (!isConnected() || el.isConnected === false) return;
1184
1219
  const val = evaluateStateExpression(state, expr);
1185
1220
  const text = val === null || val === undefined ? '' : String(val);
1186
1221
  if (el.textContent !== text) {
1187
1222
  el.textContent = text;
1188
1223
  }
1189
1224
  });
1190
- disposers.push(stop);
1225
+ elDisposers.push(stop);
1191
1226
  }
1192
1227
 
1193
1228
  // 2. data-w-show
@@ -1195,10 +1230,10 @@ function createDatastarAdapter(options = {}) {
1195
1230
  const expr = el.getAttribute('data-w-show');
1196
1231
  const initialDisplay = el.style && el.style.display && el.style.display !== 'none' ? el.style.display : '';
1197
1232
  const stop = engine.effect(() => {
1198
- if (!isConnected()) return;
1233
+ if (!isConnected() || el.isConnected === false) return;
1199
1234
  toggleElementVisibility(el, Boolean(evaluateStateExpression(state, expr)), initialDisplay);
1200
1235
  });
1201
- disposers.push(stop);
1236
+ elDisposers.push(stop);
1202
1237
  }
1203
1238
 
1204
1239
  // 2b. data-w-loading & data-w-loading-hide (declarative action/task busy indicators)
@@ -1211,7 +1246,7 @@ function createDatastarAdapter(options = {}) {
1211
1246
  .split(/\s+/)
1212
1247
  .filter(Boolean);
1213
1248
  const stop = engine.effect(() => {
1214
- if (!isConnected()) return;
1249
+ if (!isConnected() || el.isConnected === false) return;
1215
1250
  const active =
1216
1251
  typeof isLoadingTargetActive === 'function'
1217
1252
  ? Boolean(isLoadingTargetActive(rawSpec))
@@ -1224,14 +1259,14 @@ function createDatastarAdapter(options = {}) {
1224
1259
  }
1225
1260
  }
1226
1261
  });
1227
- disposers.push(stop);
1262
+ elDisposers.push(stop);
1228
1263
  }
1229
1264
 
1230
1265
  // 3. data-w-disabled
1231
1266
  if (el.hasAttribute('data-w-disabled')) {
1232
1267
  const expr = el.getAttribute('data-w-disabled');
1233
1268
  const stop = engine.effect(() => {
1234
- if (!isConnected()) return;
1269
+ if (!isConnected() || el.isConnected === false) return;
1235
1270
  const disabled = Boolean(evaluateStateExpression(state, expr));
1236
1271
  if ('disabled' in el) {
1237
1272
  el.disabled = disabled;
@@ -1244,7 +1279,7 @@ function createDatastarAdapter(options = {}) {
1244
1279
  el.removeAttribute('aria-disabled');
1245
1280
  }
1246
1281
  });
1247
- disposers.push(stop);
1282
+ elDisposers.push(stop);
1248
1283
  }
1249
1284
 
1250
1285
  // 4. data-w-model (two-way binding between input/select/textarea and controller state)
@@ -1286,7 +1321,7 @@ function createDatastarAdapter(options = {}) {
1286
1321
  };
1287
1322
  el.addEventListener('compositionstart', onCompositionStart);
1288
1323
  el.addEventListener('compositionend', onCompositionEnd);
1289
- disposers.push(() => {
1324
+ elDisposers.push(() => {
1290
1325
  try {
1291
1326
  el.removeEventListener('compositionstart', onCompositionStart);
1292
1327
  el.removeEventListener('compositionend', onCompositionEnd);
@@ -1295,7 +1330,7 @@ function createDatastarAdapter(options = {}) {
1295
1330
 
1296
1331
  // State -> DOM effect (synchronizes changes in state to the DOM control)
1297
1332
  const stopModelEffect = engine.effect(() => {
1298
- if (!isConnected() || isComposing) return;
1333
+ if (!isConnected() || el.isConnected === false || isComposing) return;
1299
1334
  const currentVal = getByPath(state, modelPath);
1300
1335
 
1301
1336
  if (type === 'checkbox') {
@@ -1319,7 +1354,7 @@ function createDatastarAdapter(options = {}) {
1319
1354
  }
1320
1355
  }
1321
1356
  });
1322
- disposers.push(stopModelEffect);
1357
+ elDisposers.push(stopModelEffect);
1323
1358
 
1324
1359
  // DOM -> State listener (synchronizes user interactions to state)
1325
1360
  const evtType =
@@ -1328,7 +1363,7 @@ function createDatastarAdapter(options = {}) {
1328
1363
  : 'input';
1329
1364
 
1330
1365
  const onModelInput = () => {
1331
- if (!isConnected() || isComposing) return;
1366
+ if (!isConnected() || el.isConnected === false || isComposing) return;
1332
1367
 
1333
1368
  if (type === 'checkbox') {
1334
1369
  const cur = getByPath(state, modelPath);
@@ -1360,7 +1395,7 @@ function createDatastarAdapter(options = {}) {
1360
1395
  };
1361
1396
 
1362
1397
  el.addEventListener(evtType, onModelInput);
1363
- disposers.push(() => el.removeEventListener(evtType, onModelInput));
1398
+ elDisposers.push(() => el.removeEventListener(evtType, onModelInput));
1364
1399
  }
1365
1400
  }
1366
1401
 
@@ -1368,23 +1403,30 @@ function createDatastarAdapter(options = {}) {
1368
1403
  const eventBindings = extractEventBindings(el);
1369
1404
  for (const binding of eventBindings) {
1370
1405
  const listener = (event) => {
1371
- if (!isConnected()) return;
1406
+ if (!isConnected() || el.isConnected === false) return;
1372
1407
  if (binding.modifiers.has('prevent')) event.preventDefault();
1373
1408
  if (binding.modifiers.has('stop')) event.stopPropagation();
1374
1409
  invokeAction(binding.actionName, event, el);
1375
1410
  };
1376
1411
  el.addEventListener(binding.eventName, listener);
1377
- disposers.push(() => el.removeEventListener(binding.eventName, listener));
1412
+ elDisposers.push(() => el.removeEventListener(binding.eventName, listener));
1378
1413
  }
1379
1414
  }
1380
1415
 
1381
1416
  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
1417
+ const map = boundElementsByInstance.get(instanceId);
1418
+ if (map) {
1419
+ for (const elDisposers of map.values()) {
1420
+ for (let i = 0; i < elDisposers.length; i++) {
1421
+ try {
1422
+ elDisposers[i]();
1423
+ } catch (_err) {
1424
+ // Ignore individual unbind errors
1425
+ }
1426
+ }
1387
1427
  }
1428
+ map.clear();
1429
+ boundElementsByInstance.delete(instanceId);
1388
1430
  }
1389
1431
  };
1390
1432
  }
@@ -1394,14 +1436,53 @@ function createDatastarAdapter(options = {}) {
1394
1436
  */
1395
1437
  function patchFragmentElement(targetEl, htmlString, lifecycleCallbacks = {}) {
1396
1438
  if (!targetEl || typeof htmlString !== 'string') return targetEl;
1397
- const parsed = parseHtmlFragment(htmlString, targetEl.ownerDocument);
1439
+ const doc = targetEl.ownerDocument || (typeof document !== 'undefined' ? document : null);
1440
+ const parsed = parseHtmlFragment(htmlString, doc);
1398
1441
  if (!parsed) return targetEl;
1399
1442
 
1443
+ // Capture active focus & text selection if inside targetEl before morphing
1444
+ let focusedEl = null;
1445
+ let selStart = null;
1446
+ let selEnd = null;
1447
+ if (
1448
+ doc &&
1449
+ doc.activeElement &&
1450
+ typeof targetEl.contains === 'function' &&
1451
+ targetEl.contains(doc.activeElement)
1452
+ ) {
1453
+ focusedEl = doc.activeElement;
1454
+ try {
1455
+ if (typeof focusedEl.selectionStart === 'number') selStart = focusedEl.selectionStart;
1456
+ if (typeof focusedEl.selectionEnd === 'number') selEnd = focusedEl.selectionEnd;
1457
+ } catch (_e) {}
1458
+ }
1459
+
1400
1460
  const morphed = morphDomTrees(targetEl, parsed, {
1401
1461
  onBeforeNodeRemove: lifecycleCallbacks.onBeforeNodeRemove,
1402
1462
  onNodeAdded: lifecycleCallbacks.onNodeAdded,
1403
1463
  });
1404
1464
 
1465
+ // Restore focus & selection range if the focused element was preserved inside targetEl
1466
+ if (
1467
+ focusedEl &&
1468
+ focusedEl.isConnected !== false &&
1469
+ typeof targetEl.contains === 'function' &&
1470
+ targetEl.contains(focusedEl)
1471
+ ) {
1472
+ try {
1473
+ if (doc && doc.activeElement !== focusedEl && typeof focusedEl.focus === 'function') {
1474
+ focusedEl.focus();
1475
+ }
1476
+ if (
1477
+ typeof selStart === 'number' &&
1478
+ typeof selEnd === 'number' &&
1479
+ typeof focusedEl.setSelectionRange === 'function'
1480
+ ) {
1481
+ focusedEl.setSelectionRange(selStart, selEnd);
1482
+ }
1483
+ } catch (_e) {}
1484
+ }
1485
+
1405
1486
  // If external @starfederation/datastar is active, notify apply() on updated subtree
1406
1487
  if (externalDatastar && typeof externalDatastar.apply === 'function') {
1407
1488
  try {
@@ -1432,6 +1513,14 @@ function createDatastarAdapter(options = {}) {
1432
1513
  setByPath,
1433
1514
  isWithinControllerScope,
1434
1515
  collectScopedElements,
1516
+ getBoundInstanceCount: () => boundElementsByInstance.size,
1517
+ getBoundElementCount: () => {
1518
+ let total = 0;
1519
+ for (const m of boundElementsByInstance.values()) {
1520
+ total += m.size;
1521
+ }
1522
+ return total;
1523
+ },
1435
1524
  };
1436
1525
  }
1437
1526
 
@@ -495,6 +495,7 @@ function createControllerRuntime(options = {}) {
495
495
  const activeInstances = new Map();
496
496
  // Standalone forms (forms without a controller wrapper)
497
497
  const standaloneForms = new WeakMap();
498
+ const activeStandaloneForms = new Set();
498
499
 
499
500
  /**
500
501
  * Register a controller definition with collision checking.
@@ -769,6 +770,7 @@ function createControllerRuntime(options = {}) {
769
770
  let _data = null;
770
771
  let _error = null;
771
772
  let _stale = false;
773
+ let _lastArgs = [];
772
774
 
773
775
  function isTaskStale(mode = true) {
774
776
  if (!mode) return false;
@@ -787,6 +789,10 @@ function createControllerRuntime(options = {}) {
787
789
 
788
790
  async function run(...args) {
789
791
  if (!isConnected()) return undefined;
792
+ if (args.length > 0) {
793
+ _lastArgs = [...args];
794
+ }
795
+ const effectiveArgs = args.length > 0 ? args : _lastArgs;
790
796
 
791
797
  if (policy === 'drop' && activeRuns > 0) {
792
798
  if (debug && typeof debug.trackDiscardedResponse === 'function') {
@@ -802,12 +808,12 @@ function createControllerRuntime(options = {}) {
802
808
 
803
809
  if (policy === 'queue') {
804
810
  const wait = queuedPromise;
805
- const nextRun = wait.then(() => executeRun(...args)).catch(() => {});
811
+ const nextRun = wait.then(() => executeRun(...effectiveArgs)).catch(() => {});
806
812
  queuedPromise = nextRun;
807
813
  return nextRun;
808
814
  }
809
815
 
810
- return executeRun(...args);
816
+ return executeRun(...effectiveArgs);
811
817
  }
812
818
 
813
819
  async function executeRun(...args) {
@@ -834,7 +840,7 @@ function createControllerRuntime(options = {}) {
834
840
  try {
835
841
  const result = await runFn(...args);
836
842
 
837
- if (!isConnected() || runVersion !== currentRunVersion) {
843
+ if (!isConnected() || (policy !== 'parallel' && runVersion !== currentRunVersion)) {
838
844
  return undefined;
839
845
  }
840
846
 
@@ -847,7 +853,7 @@ function createControllerRuntime(options = {}) {
847
853
  updateSignal('stale', false);
848
854
  return result;
849
855
  } catch (err) {
850
- if (!isConnected() || runVersion !== currentRunVersion) {
856
+ if (!isConnected() || (policy !== 'parallel' && runVersion !== currentRunVersion)) {
851
857
  return undefined;
852
858
  }
853
859
  if (err && (err.isCanceled || err.name === 'AbortError')) {
@@ -865,7 +871,11 @@ function createControllerRuntime(options = {}) {
865
871
  inFlightAbort = null;
866
872
  }
867
873
  activeRuns = Math.max(0, activeRuns - 1);
868
- if (runVersion === currentRunVersion || activeRuns === 0) {
874
+ const shouldClearPending =
875
+ policy === 'parallel'
876
+ ? activeRuns === 0
877
+ : runVersion === currentRunVersion || activeRuns === 0;
878
+ if (shouldClearPending) {
869
879
  _pending = false;
870
880
  _stale = false;
871
881
  if (isConnected()) {
@@ -912,6 +922,9 @@ function createControllerRuntime(options = {}) {
912
922
  get lastUpdatedAt() {
913
923
  return _lastUpdatedAt;
914
924
  },
925
+ get lastArgs() {
926
+ return [..._lastArgs];
927
+ },
915
928
  isStale: () => isTaskStale(true),
916
929
  run,
917
930
  cancel,
@@ -1359,6 +1372,9 @@ function createControllerRuntime(options = {}) {
1359
1372
  root: defaultStorage,
1360
1373
  });
1361
1374
  const persistConfig = normalizePersistConfig(reg.definition.persist, controllerName);
1375
+ let persistTimer = null;
1376
+ let lastSavedJson = '';
1377
+ const persistenceConflicts = [];
1362
1378
  function resolvePersistDriver(pConfig) {
1363
1379
  if (!pConfig) return storageCtx.local;
1364
1380
  if (pConfig.isGlobal) {
@@ -1367,6 +1383,30 @@ function createControllerRuntime(options = {}) {
1367
1383
  return storageCtx[pConfig.storageType] || storageCtx.local;
1368
1384
  }
1369
1385
 
1386
+ function checkFragmentPersistenceConflict(idOrName, input) {
1387
+ if (!persistConfig || !fragments || typeof fragments.getInstance !== 'function' || !input || typeof input !== 'object') {
1388
+ return;
1389
+ }
1390
+ const inst =
1391
+ fragments.getInstance(idOrName) ||
1392
+ (typeof fragments.getInstances === 'function'
1393
+ ? fragments.getInstances().find((i) => i.name === idOrName)
1394
+ : null);
1395
+ if (inst && inst.persistInput && inst.persistInput.key !== persistConfig.storageKey) {
1396
+ const conflict = {
1397
+ controller: controllerName,
1398
+ controllerStorageKey: persistConfig.storageKey,
1399
+ fragment: inst.instanceId,
1400
+ fragmentStorageKey: inst.persistInput.key,
1401
+ keys: Object.keys(input),
1402
+ };
1403
+ persistenceConflicts.push(conflict);
1404
+ if (debug && typeof debug.recordEvent === 'function') {
1405
+ debug.recordEvent('persist:conflict', conflict);
1406
+ }
1407
+ }
1408
+ }
1409
+
1370
1410
  // 6. Context object passed to `connect(ctx)` and `actions[name](ctx, event, actionEl)`
1371
1411
  ctx = Object.freeze({
1372
1412
  instanceId,
@@ -1383,10 +1423,27 @@ function createControllerRuntime(options = {}) {
1383
1423
  tasks: tasksProxy,
1384
1424
  task: createTaskInstance,
1385
1425
  storage: storageCtx,
1426
+ get persistenceConflicts() {
1427
+ return [...persistenceConflicts];
1428
+ },
1386
1429
  clearPersistedState: () => {
1430
+ if (persistTimer) {
1431
+ clearTimeout(persistTimer);
1432
+ persistTimer = null;
1433
+ }
1387
1434
  if (persistConfig) {
1388
1435
  const driver = resolvePersistDriver(persistConfig);
1389
1436
  driver.remove(persistConfig.storageKey);
1437
+ if (state && initialRawState) {
1438
+ const keysToTrack = persistConfig.keys || Object.keys(initialRawState);
1439
+ const currentPayload = {};
1440
+ for (const k of keysToTrack) {
1441
+ currentPayload[k] = state[k];
1442
+ }
1443
+ lastSavedJson = JSON.stringify(currentPayload);
1444
+ } else {
1445
+ lastSavedJson = '';
1446
+ }
1390
1447
  }
1391
1448
  },
1392
1449
  param: (name, maybeEl) => {
@@ -1430,7 +1487,12 @@ function createControllerRuntime(options = {}) {
1430
1487
  return unregister;
1431
1488
  },
1432
1489
  invalidate: (tags) => (fragments ? fragments.invalidate(tags) : Promise.resolve({ ok: true, refreshed: [] })),
1490
+ retryFailedFragments: () =>
1491
+ fragments && typeof fragments.retryFailed === 'function'
1492
+ ? fragments.retryFailed()
1493
+ : Promise.resolve({ refreshed: [], skipped: true }),
1433
1494
  refresh: async (idOrName, input, opts) => {
1495
+ checkFragmentPersistenceConflict(idOrName, input);
1434
1496
  if (!fragments) {
1435
1497
  const empty = { ok: true, refreshed: [], skipped: true };
1436
1498
  if (opts && typeof opts.onSuccess === 'function') await opts.onSuccess(empty);
@@ -1446,6 +1508,7 @@ function createControllerRuntime(options = {}) {
1446
1508
  return inst && inst.input ? { ...inst.input } : {};
1447
1509
  },
1448
1510
  refresh: (input, opts) => {
1511
+ checkFragmentPersistenceConflict(idOrName, input);
1449
1512
  if (!fragments) {
1450
1513
  const empty = { ok: true, refreshed: [], skipped: true };
1451
1514
  if (opts && typeof opts.onSuccess === 'function') {
@@ -1478,14 +1541,34 @@ function createControllerRuntime(options = {}) {
1478
1541
  );
1479
1542
  state = stateManager.state;
1480
1543
 
1481
- // Hydrate persisted state if defined
1544
+ // Hydrate persisted state if defined (with schema-compatible type validation against corrupted storage)
1482
1545
  if (persistConfig) {
1483
1546
  const driver = resolvePersistDriver(persistConfig);
1484
1547
  const savedData = driver.get(persistConfig.storageKey, null);
1485
1548
  if (savedData && typeof savedData === 'object' && !isArray(savedData)) {
1486
1549
  for (const [k, v] of Object.entries(savedData)) {
1487
1550
  if (persistConfig.keys && !persistConfig.keys.includes(k)) continue;
1488
- if (Object.prototype.hasOwnProperty.call(initialRawState, k)) {
1551
+ if (!Object.prototype.hasOwnProperty.call(initialRawState, k)) continue;
1552
+ const initVal = initialRawState[k];
1553
+ if (initVal !== null && typeof initVal === 'object' && !isArray(initVal)) {
1554
+ if (v !== null && typeof v === 'object' && !isArray(v)) {
1555
+ const merged = { ...initVal };
1556
+ for (const [subKey, subVal] of Object.entries(v)) {
1557
+ if (Object.prototype.hasOwnProperty.call(initVal, subKey)) {
1558
+ if (initVal[subKey] === null || typeof subVal === typeof initVal[subKey]) {
1559
+ merged[subKey] = subVal;
1560
+ }
1561
+ } else {
1562
+ merged[subKey] = subVal;
1563
+ }
1564
+ }
1565
+ state[k] = merged;
1566
+ }
1567
+ } else if (isArray(initVal)) {
1568
+ if (isArray(v)) {
1569
+ state[k] = v;
1570
+ }
1571
+ } else if (initVal === null || initVal === undefined || typeof v === typeof initVal) {
1489
1572
  state[k] = v;
1490
1573
  }
1491
1574
  }
@@ -1609,7 +1692,17 @@ function createControllerRuntime(options = {}) {
1609
1692
  isConnected,
1610
1693
  isLoadingTargetActive,
1611
1694
  });
1612
- domDisposers.push(unbind);
1695
+ if (domDisposers.length === 0) {
1696
+ domDisposers.push(unbind);
1697
+ }
1698
+
1699
+ // Prune disconnected forms from scopedForms
1700
+ for (const [fName, fh] of Array.from(scopedForms.entries())) {
1701
+ if (!fh.el || fh.el.isConnected === false || !rootEl.contains(fh.el)) {
1702
+ fh.destroy();
1703
+ scopedForms.delete(fName);
1704
+ }
1705
+ }
1613
1706
 
1614
1707
  // Also auto-initialize any `<form data-w-form>` inside this controller's scope
1615
1708
  if (rootEl.tagName === 'FORM' && rootEl.hasAttribute('data-w-form')) {
@@ -1619,7 +1712,10 @@ function createControllerRuntime(options = {}) {
1619
1712
  const formsInScope = rootEl.querySelectorAll('form[data-w-form]');
1620
1713
  for (let i = 0; i < formsInScope.length; i++) {
1621
1714
  if (isTargetInControllerScope(rootEl, formsInScope[i])) {
1622
- createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
1715
+ const fh = createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
1716
+ if (fh && fh.name) {
1717
+ scopedForms.set(fh.name, fh);
1718
+ }
1623
1719
  }
1624
1720
  }
1625
1721
  }
@@ -1630,8 +1726,6 @@ function createControllerRuntime(options = {}) {
1630
1726
  // Auto-sync persisted state changes back to storage
1631
1727
  if (persistConfig) {
1632
1728
  const driver = resolvePersistDriver(persistConfig);
1633
- let persistTimer = null;
1634
- let lastSavedJson = '';
1635
1729
 
1636
1730
  const stopPersistEffect = registerEffect(() => {
1637
1731
  if (!isConnected()) return;
@@ -1816,6 +1910,11 @@ function createControllerRuntime(options = {}) {
1816
1910
  if (standaloneForm) {
1817
1911
  standaloneForm.destroy();
1818
1912
  standaloneForms.delete(node);
1913
+ activeStandaloneForms.delete(standaloneForm);
1914
+ } else if (node && node.__wFormHandle) {
1915
+ try {
1916
+ node.__wFormHandle.destroy();
1917
+ } catch (_e) {}
1819
1918
  }
1820
1919
  }
1821
1920
  }
@@ -1901,6 +2000,7 @@ function createControllerRuntime(options = {}) {
1901
2000
  services,
1902
2001
  });
1903
2002
  standaloneForms.set(formEl, handle);
2003
+ activeStandaloneForms.add(handle);
1904
2004
  }
1905
2005
  }
1906
2006
 
@@ -1944,10 +2044,19 @@ function createControllerRuntime(options = {}) {
1944
2044
  getActiveInstances() {
1945
2045
  return Array.from(activeInstances.values());
1946
2046
  },
2047
+ getStandaloneFormCount() {
2048
+ return activeStandaloneForms.size;
2049
+ },
1947
2050
  destroyAll() {
1948
2051
  for (const inst of Array.from(activeInstances.values())) {
1949
2052
  inst.destroy();
1950
2053
  }
2054
+ for (const fh of Array.from(activeStandaloneForms.values())) {
2055
+ try {
2056
+ fh.destroy();
2057
+ } catch (_e) {}
2058
+ }
2059
+ activeStandaloneForms.clear();
1951
2060
  },
1952
2061
  };
1953
2062
  }