webspresso 0.0.110 → 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.
@@ -18,7 +18,15 @@
18
18
  const { createFormHandle } = require('./forms');
19
19
  const { createApiClient } = require('./api');
20
20
  const { defaultStorage, createClientStorage } = require('./storage');
21
- const { decorateElement } = require('./utils');
21
+ const { createClientServiceRegistry } = require('./services');
22
+ const {
23
+ isArray,
24
+ isNumber,
25
+ decorateElement,
26
+ parseDurationMs,
27
+ parseRefetchMode,
28
+ setElementBusyState,
29
+ } = require('./utils');
22
30
 
23
31
  let instanceSeq = 0;
24
32
  let activeControllerContext = null;
@@ -43,7 +51,7 @@ function deepTouch(val, visited = new Set()) {
43
51
  return;
44
52
  }
45
53
  visited.add(val);
46
- if (Array.isArray(val)) {
54
+ if (isArray(val)) {
47
55
  for (let i = 0; i < val.length; i++) {
48
56
  deepTouch(val[i], visited);
49
57
  }
@@ -60,14 +68,14 @@ function deepCloneOrSnapshot(val) {
60
68
  try {
61
69
  return JSON.parse(JSON.stringify(val));
62
70
  } catch {
63
- return Array.isArray(val) ? [...val] : { ...val };
71
+ return isArray(val) ? [...val] : { ...val };
64
72
  }
65
73
  }
66
74
 
67
75
  function deepEqual(a, b) {
68
76
  if (Object.is(a, b)) return true;
69
77
  if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') return false;
70
- if (Array.isArray(a) !== Array.isArray(b)) return false;
78
+ if (isArray(a) !== isArray(b)) return false;
71
79
  const keysA = Object.keys(a);
72
80
  const keysB = Object.keys(b);
73
81
  if (keysA.length !== keysB.length) return false;
@@ -81,7 +89,7 @@ function deepEqual(a, b) {
81
89
  }
82
90
 
83
91
  function hasArrayChanged(newArr, oldArr) {
84
- if (!Array.isArray(newArr) || !Array.isArray(oldArr)) return !Object.is(newArr, oldArr);
92
+ if (!isArray(newArr) || !isArray(oldArr)) return !Object.is(newArr, oldArr);
85
93
  if (newArr.length !== oldArr.length) return true;
86
94
  for (let i = 0; i < newArr.length; i++) {
87
95
  if (!Object.is(newArr[i], oldArr[i])) return true;
@@ -108,7 +116,7 @@ function createStandaloneWatch(engine, source, callback, options = {}) {
108
116
  let getter;
109
117
  if (typeof source === 'function') {
110
118
  getter = source;
111
- } else if (Array.isArray(source)) {
119
+ } else if (isArray(source)) {
112
120
  getter = () => source.map((s) => (typeof s === 'function' ? s() : s));
113
121
  } else {
114
122
  getter = () => source;
@@ -147,14 +155,14 @@ function createStandaloneWatch(engine, source, callback, options = {}) {
147
155
  let newValue;
148
156
  try {
149
157
  newValue = getter();
150
- if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !Array.isArray(source))) && newValue && typeof newValue === 'object') {
158
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !isArray(source))) && newValue && typeof newValue === 'object') {
151
159
  deepTouch(newValue);
152
160
  }
153
161
  } catch (_err) {
154
162
  return;
155
163
  }
156
164
 
157
- const isArr = Array.isArray(source);
165
+ const isArr = isArray(source);
158
166
 
159
167
  if (isFirstRun) {
160
168
  isFirstRun = false;
@@ -325,7 +333,7 @@ function defineController(definition = {}) {
325
333
  }
326
334
  if (definition.persist !== undefined && definition.persist !== null) {
327
335
  const p = definition.persist;
328
- const isArr = Array.isArray(p);
336
+ const isArr = isArray(p);
329
337
  const isStr = typeof p === 'string';
330
338
  const isObj = typeof p === 'object' && p !== null && !isArr;
331
339
  if (!isArr && !isStr && !isObj) {
@@ -381,13 +389,13 @@ function normalizePersistConfig(persistOpt, controllerName) {
381
389
  let isGlobal = false;
382
390
  let debounce = 50;
383
391
 
384
- if (Array.isArray(persistOpt)) {
392
+ if (isArray(persistOpt)) {
385
393
  keys = persistOpt.map(String);
386
394
  } else if (typeof persistOpt === 'string') {
387
395
  const s = persistOpt.trim().toLowerCase();
388
396
  storageType = s === 'session' ? 'session' : 'local';
389
397
  } else if (typeof persistOpt === 'object' && persistOpt !== null) {
390
- if (Array.isArray(persistOpt.keys)) {
398
+ if (isArray(persistOpt.keys)) {
391
399
  keys = persistOpt.keys.map(String);
392
400
  }
393
401
  if (persistOpt.storage === 'session' || persistOpt.storage === 'memory') {
@@ -399,7 +407,7 @@ function normalizePersistConfig(persistOpt, controllerName) {
399
407
  if (Boolean(persistOpt.global)) {
400
408
  isGlobal = true;
401
409
  }
402
- if (typeof persistOpt.debounce === 'number') {
410
+ if (isNumber(persistOpt.debounce)) {
403
411
  debounce = Math.max(0, persistOpt.debounce);
404
412
  }
405
413
  }
@@ -470,6 +478,14 @@ function createControllerRuntime(options = {}) {
470
478
  const debug = options.debug || null;
471
479
  const baseApiConfig = options.apiConfig || {};
472
480
  const lazyLoader = typeof options.lazyLoader === 'function' ? options.lazyLoader : null;
481
+ const services =
482
+ options.services && typeof options.services.get === 'function'
483
+ ? options.services
484
+ : createClientServiceRegistry({
485
+ root: options.root || null,
486
+ debug,
487
+ services: options.services || undefined,
488
+ });
473
489
 
474
490
  // Registered controller definitions: name -> { name, definition, sourcePath }
475
491
  const definitions = new Map();
@@ -479,6 +495,7 @@ function createControllerRuntime(options = {}) {
479
495
  const activeInstances = new Map();
480
496
  // Standalone forms (forms without a controller wrapper)
481
497
  const standaloneForms = new WeakMap();
498
+ const activeStandaloneForms = new Set();
482
499
 
483
500
  /**
484
501
  * Register a controller definition with collision checking.
@@ -534,7 +551,7 @@ function createControllerRuntime(options = {}) {
534
551
  };
535
552
  definitions.set(canonicalName, entry);
536
553
 
537
- if (Array.isArray(meta.aliases)) {
554
+ if (isArray(meta.aliases)) {
538
555
  for (const alias of meta.aliases) {
539
556
  if (alias && !definitions.has(alias)) {
540
557
  definitions.set(alias, entry);
@@ -587,6 +604,79 @@ function createControllerRuntime(options = {}) {
587
604
  const scopedForms = new Map();
588
605
  const scopedTasks = {};
589
606
  const scopedTasksList = [];
607
+ const busyActionCounts = new Map();
608
+ let totalBusyActions = 0;
609
+ const busySignalPrefix = `__w_inst_${instanceId}.__busy`;
610
+ datastar.engine.signals.setValue(`${busySignalPrefix}.any`, false);
611
+
612
+ function syncAnyBusySignal() {
613
+ let busy = totalBusyActions > 0;
614
+ if (!busy) {
615
+ for (let i = 0; i < scopedTasksList.length; i++) {
616
+ if (scopedTasksList[i].pending) {
617
+ busy = true;
618
+ break;
619
+ }
620
+ }
621
+ }
622
+ datastar.engine.signals.setValue(`${busySignalPrefix}.any`, busy);
623
+ }
624
+
625
+ function setActionBusy(actionName, delta) {
626
+ const prevCount = busyActionCounts.get(actionName) || 0;
627
+ const nextCount = Math.max(0, prevCount + delta);
628
+ if (nextCount === 0) {
629
+ busyActionCounts.delete(actionName);
630
+ } else {
631
+ busyActionCounts.set(actionName, nextCount);
632
+ }
633
+ totalBusyActions = Math.max(0, totalBusyActions + delta);
634
+ datastar.engine.signals.setValue(`${busySignalPrefix}.action_${actionName}`, nextCount > 0);
635
+ syncAnyBusySignal();
636
+ }
637
+
638
+ function isActionBusy(actionName) {
639
+ if (!actionName) return false;
640
+ datastar.engine.signals.value(`${busySignalPrefix}.action_${actionName}`);
641
+ return (busyActionCounts.get(actionName) || 0) > 0;
642
+ }
643
+
644
+ function checkIsBusy() {
645
+ datastar.engine.signals.value(`${busySignalPrefix}.any`);
646
+ if (totalBusyActions > 0) return true;
647
+ for (let i = 0; i < scopedTasksList.length; i++) {
648
+ if (scopedTasksList[i].pending) return true;
649
+ }
650
+ return false;
651
+ }
652
+
653
+ function isLoadingTargetActive(rawSpec) {
654
+ const spec = typeof rawSpec === 'string' ? rawSpec.trim() : '';
655
+ if (!spec || spec === 'true') {
656
+ return checkIsBusy();
657
+ }
658
+ const parts = spec.split(',').map((s) => s.trim()).filter(Boolean);
659
+ for (let i = 0; i < parts.length; i++) {
660
+ const token = parts[i];
661
+ if (reg.definition.actions && Object.prototype.hasOwnProperty.call(reg.definition.actions, token)) {
662
+ if (isActionBusy(token)) return true;
663
+ continue;
664
+ }
665
+ if (
666
+ Object.prototype.hasOwnProperty.call(scopedTasks, token) ||
667
+ (reg.definition.tasks && Object.prototype.hasOwnProperty.call(reg.definition.tasks, token))
668
+ ) {
669
+ const t = getTask(token);
670
+ if (t && t.pending) return true;
671
+ continue;
672
+ }
673
+ if (Boolean(datastar.evaluateStateExpression(state, token))) {
674
+ return true;
675
+ }
676
+ }
677
+ return false;
678
+ }
679
+
590
680
  let currentActionEl = null;
591
681
  let lastError = null;
592
682
  const dynamicErrorListeners = new Set();
@@ -647,10 +737,29 @@ function createControllerRuntime(options = {}) {
647
737
  const taskKey = taskOpts.key || `task_${++taskSeq}`;
648
738
  const signalPrefix = `__w_inst_${instanceId}.task_${taskKey}`;
649
739
 
740
+ // React Query-style automatic revalidation options for tasks
741
+ const staleTimeMs = parseDurationMs(taskOpts.staleTime, 0);
742
+ const refetchOnMountMode = parseRefetchMode(
743
+ taskOpts.refetchOnMount !== undefined ? taskOpts.refetchOnMount : taskOpts.refetchOnVisit,
744
+ false
745
+ );
746
+ const refetchOnFocusMode = parseRefetchMode(
747
+ taskOpts.refetchOnWindowFocus !== undefined ? taskOpts.refetchOnWindowFocus : taskOpts.refetchOnFocus,
748
+ false
749
+ );
750
+ const refetchOnReconnectMode = parseRefetchMode(taskOpts.refetchOnReconnect, false);
751
+ const refetchIntervalMs = parseDurationMs(taskOpts.refetchInterval, 0);
752
+ const refetchIntervalInBg =
753
+ parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
754
+
650
755
  let currentRunVersion = 0;
651
756
  let activeRuns = 0;
652
757
  let inFlightAbort = null;
653
758
  let queuedPromise = Promise.resolve();
759
+ let _lastUpdatedAt = 0;
760
+ let taskIntervalTimer = null;
761
+ let focusDebounceTimer = null;
762
+ const taskCleanupListeners = [];
654
763
 
655
764
  datastar.engine.signals.setValue(`${signalPrefix}.pending`, false);
656
765
  datastar.engine.signals.setValue(`${signalPrefix}.data`, null);
@@ -661,13 +770,29 @@ function createControllerRuntime(options = {}) {
661
770
  let _data = null;
662
771
  let _error = null;
663
772
  let _stale = false;
773
+ let _lastArgs = [];
774
+
775
+ function isTaskStale(mode = true) {
776
+ if (!mode) return false;
777
+ if (mode === 'always') return true;
778
+ if (_lastUpdatedAt === 0) return true;
779
+ if (staleTimeMs <= 0) return true;
780
+ return Date.now() - _lastUpdatedAt >= staleTimeMs;
781
+ }
664
782
 
665
783
  function updateSignal(name, val) {
666
784
  datastar.engine.signals.setValue(`${signalPrefix}.${name}`, val);
785
+ if (name === 'pending') {
786
+ syncAnyBusySignal();
787
+ }
667
788
  }
668
789
 
669
790
  async function run(...args) {
670
791
  if (!isConnected()) return undefined;
792
+ if (args.length > 0) {
793
+ _lastArgs = [...args];
794
+ }
795
+ const effectiveArgs = args.length > 0 ? args : _lastArgs;
671
796
 
672
797
  if (policy === 'drop' && activeRuns > 0) {
673
798
  if (debug && typeof debug.trackDiscardedResponse === 'function') {
@@ -683,12 +808,12 @@ function createControllerRuntime(options = {}) {
683
808
 
684
809
  if (policy === 'queue') {
685
810
  const wait = queuedPromise;
686
- const nextRun = wait.then(() => executeRun(...args)).catch(() => {});
811
+ const nextRun = wait.then(() => executeRun(...effectiveArgs)).catch(() => {});
687
812
  queuedPromise = nextRun;
688
813
  return nextRun;
689
814
  }
690
815
 
691
- return executeRun(...args);
816
+ return executeRun(...effectiveArgs);
692
817
  }
693
818
 
694
819
  async function executeRun(...args) {
@@ -715,19 +840,20 @@ function createControllerRuntime(options = {}) {
715
840
  try {
716
841
  const result = await runFn(...args);
717
842
 
718
- if (!isConnected() || runVersion !== currentRunVersion) {
843
+ if (!isConnected() || (policy !== 'parallel' && runVersion !== currentRunVersion)) {
719
844
  return undefined;
720
845
  }
721
846
 
722
847
  _data = result;
723
848
  _error = null;
724
849
  _stale = false;
850
+ _lastUpdatedAt = Date.now();
725
851
  updateSignal('data', result);
726
852
  updateSignal('error', null);
727
853
  updateSignal('stale', false);
728
854
  return result;
729
855
  } catch (err) {
730
- if (!isConnected() || runVersion !== currentRunVersion) {
856
+ if (!isConnected() || (policy !== 'parallel' && runVersion !== currentRunVersion)) {
731
857
  return undefined;
732
858
  }
733
859
  if (err && (err.isCanceled || err.name === 'AbortError')) {
@@ -745,7 +871,11 @@ function createControllerRuntime(options = {}) {
745
871
  inFlightAbort = null;
746
872
  }
747
873
  activeRuns = Math.max(0, activeRuns - 1);
748
- if (runVersion === currentRunVersion || activeRuns === 0) {
874
+ const shouldClearPending =
875
+ policy === 'parallel'
876
+ ? activeRuns === 0
877
+ : runVersion === currentRunVersion || activeRuns === 0;
878
+ if (shouldClearPending) {
749
879
  _pending = false;
750
880
  _stale = false;
751
881
  if (isConnected()) {
@@ -787,16 +917,106 @@ function createControllerRuntime(options = {}) {
787
917
  },
788
918
  get stale() {
789
919
  datastar.engine.signals.value(`${signalPrefix}.stale`);
790
- return _stale;
920
+ return _stale || (_lastUpdatedAt > 0 && staleTimeMs > 0 && Date.now() - _lastUpdatedAt >= staleTimeMs);
791
921
  },
922
+ get lastUpdatedAt() {
923
+ return _lastUpdatedAt;
924
+ },
925
+ get lastArgs() {
926
+ return [..._lastArgs];
927
+ },
928
+ isStale: () => isTaskStale(true),
792
929
  run,
793
930
  cancel,
794
931
  dispose() {
932
+ if (taskIntervalTimer) {
933
+ clearInterval(taskIntervalTimer);
934
+ taskIntervalTimer = null;
935
+ }
936
+ if (focusDebounceTimer) {
937
+ clearTimeout(focusDebounceTimer);
938
+ focusDebounceTimer = null;
939
+ }
940
+ while (taskCleanupListeners.length > 0) {
941
+ const removeFn = taskCleanupListeners.pop();
942
+ try {
943
+ removeFn();
944
+ } catch (_e) {}
945
+ }
795
946
  cancel();
796
947
  datastar.engine.signals.remove(signalPrefix);
797
948
  },
798
949
  };
799
950
 
951
+ // Set up automatic background revalidation (mount / focus / reconnect / interval)
952
+ const doc = (rootEl && rootEl.ownerDocument) || (typeof document !== 'undefined' ? document : null);
953
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
954
+
955
+ function isDocHidden() {
956
+ return Boolean(doc && doc.visibilityState === 'hidden');
957
+ }
958
+
959
+ if (refetchOnMountMode) {
960
+ Promise.resolve().then(() => {
961
+ if (isConnected() && isTaskStale(refetchOnMountMode)) {
962
+ run().catch(() => {});
963
+ }
964
+ });
965
+ }
966
+
967
+ if (refetchOnFocusMode) {
968
+ const onTaskFocus = () => {
969
+ if (!isConnected() || isDocHidden()) return;
970
+ if (focusDebounceTimer) clearTimeout(focusDebounceTimer);
971
+ focusDebounceTimer = setTimeout(() => {
972
+ focusDebounceTimer = null;
973
+ if (isConnected() && !isDocHidden() && activeRuns === 0 && isTaskStale(refetchOnFocusMode)) {
974
+ run().catch(() => {});
975
+ }
976
+ }, 20);
977
+ };
978
+ const onVisChange = () => {
979
+ if (!isDocHidden()) onTaskFocus();
980
+ };
981
+ if (doc && typeof doc.addEventListener === 'function') {
982
+ doc.addEventListener('visibilitychange', onVisChange);
983
+ taskCleanupListeners.push(() => doc.removeEventListener('visibilitychange', onVisChange));
984
+ }
985
+ if (win && typeof win.addEventListener === 'function') {
986
+ win.addEventListener('focus', onTaskFocus);
987
+ taskCleanupListeners.push(() => win.removeEventListener('focus', onTaskFocus));
988
+ }
989
+ }
990
+
991
+ if (refetchOnReconnectMode && win && typeof win.addEventListener === 'function') {
992
+ const onTaskOnline = () => {
993
+ if (isConnected() && activeRuns === 0 && isTaskStale(refetchOnReconnectMode)) {
994
+ run().catch(() => {});
995
+ }
996
+ };
997
+ win.addEventListener('online', onTaskOnline);
998
+ taskCleanupListeners.push(() => win.removeEventListener('online', onTaskOnline));
999
+ }
1000
+
1001
+ if (refetchIntervalMs > 0) {
1002
+ taskIntervalTimer = setInterval(() => {
1003
+ if (!isConnected()) {
1004
+ if (taskIntervalTimer) {
1005
+ clearInterval(taskIntervalTimer);
1006
+ taskIntervalTimer = null;
1007
+ }
1008
+ return;
1009
+ }
1010
+ if (!refetchIntervalInBg && isDocHidden()) return;
1011
+ if (activeRuns === 0) {
1012
+ run().catch(() => {});
1013
+ }
1014
+ }, refetchIntervalMs);
1015
+ if (taskIntervalTimer && typeof taskIntervalTimer.unref === 'function') {
1016
+ taskIntervalTimer.unref();
1017
+ }
1018
+ }
1019
+
800
1020
  scopedTasksList.push(taskObj);
801
1021
  return taskObj;
802
1022
  }
@@ -826,7 +1046,7 @@ function createControllerRuntime(options = {}) {
826
1046
  getter = () => source(ctx);
827
1047
  } else if (typeof source === 'string') {
828
1048
  getter = () => resolveStatePath(state, source);
829
- } else if (Array.isArray(source)) {
1049
+ } else if (isArray(source)) {
830
1050
  getter = () =>
831
1051
  source.map((s) =>
832
1052
  typeof s === 'function'
@@ -876,7 +1096,7 @@ function createControllerRuntime(options = {}) {
876
1096
  let newValue;
877
1097
  try {
878
1098
  newValue = getter();
879
- if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !Array.isArray(source))) && newValue && typeof newValue === 'object') {
1099
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !isArray(source))) && newValue && typeof newValue === 'object') {
880
1100
  deepTouch(newValue);
881
1101
  }
882
1102
  } catch (err) {
@@ -884,7 +1104,7 @@ function createControllerRuntime(options = {}) {
884
1104
  return;
885
1105
  }
886
1106
 
887
- const isArr = Array.isArray(source);
1107
+ const isArr = isArray(source);
888
1108
 
889
1109
  if (isFirstRun) {
890
1110
  isFirstRun = false;
@@ -1069,6 +1289,7 @@ function createControllerRuntime(options = {}) {
1069
1289
  api: scopedApi,
1070
1290
  datastar,
1071
1291
  debug,
1292
+ services,
1072
1293
  ...formOptions,
1073
1294
  });
1074
1295
  scopedForms.set(handle.name, handle);
@@ -1151,6 +1372,9 @@ function createControllerRuntime(options = {}) {
1151
1372
  root: defaultStorage,
1152
1373
  });
1153
1374
  const persistConfig = normalizePersistConfig(reg.definition.persist, controllerName);
1375
+ let persistTimer = null;
1376
+ let lastSavedJson = '';
1377
+ const persistenceConflicts = [];
1154
1378
  function resolvePersistDriver(pConfig) {
1155
1379
  if (!pConfig) return storageCtx.local;
1156
1380
  if (pConfig.isGlobal) {
@@ -1159,6 +1383,30 @@ function createControllerRuntime(options = {}) {
1159
1383
  return storageCtx[pConfig.storageType] || storageCtx.local;
1160
1384
  }
1161
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
+
1162
1410
  // 6. Context object passed to `connect(ctx)` and `actions[name](ctx, event, actionEl)`
1163
1411
  ctx = Object.freeze({
1164
1412
  instanceId,
@@ -1168,16 +1416,34 @@ function createControllerRuntime(options = {}) {
1168
1416
  return state;
1169
1417
  },
1170
1418
  api: scopedApi,
1419
+ services,
1171
1420
  forms: formsAccessor,
1172
1421
  target: targetAccessor,
1173
1422
  targets: targetsAccessor,
1174
1423
  tasks: tasksProxy,
1175
1424
  task: createTaskInstance,
1176
1425
  storage: storageCtx,
1426
+ get persistenceConflicts() {
1427
+ return [...persistenceConflicts];
1428
+ },
1177
1429
  clearPersistedState: () => {
1430
+ if (persistTimer) {
1431
+ clearTimeout(persistTimer);
1432
+ persistTimer = null;
1433
+ }
1178
1434
  if (persistConfig) {
1179
1435
  const driver = resolvePersistDriver(persistConfig);
1180
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
+ }
1181
1447
  }
1182
1448
  },
1183
1449
  param: (name, maybeEl) => {
@@ -1201,6 +1467,10 @@ function createControllerRuntime(options = {}) {
1201
1467
  signal: lifecycleAbort.signal,
1202
1468
  isConnected,
1203
1469
  isDisposed: () => !isConnected(),
1470
+ get isBusy() {
1471
+ return checkIsBusy();
1472
+ },
1473
+ isActionBusy,
1204
1474
  get lastError() {
1205
1475
  return lastError;
1206
1476
  },
@@ -1217,7 +1487,12 @@ function createControllerRuntime(options = {}) {
1217
1487
  return unregister;
1218
1488
  },
1219
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 }),
1220
1494
  refresh: async (idOrName, input, opts) => {
1495
+ checkFragmentPersistenceConflict(idOrName, input);
1221
1496
  if (!fragments) {
1222
1497
  const empty = { ok: true, refreshed: [], skipped: true };
1223
1498
  if (opts && typeof opts.onSuccess === 'function') await opts.onSuccess(empty);
@@ -1233,6 +1508,7 @@ function createControllerRuntime(options = {}) {
1233
1508
  return inst && inst.input ? { ...inst.input } : {};
1234
1509
  },
1235
1510
  refresh: (input, opts) => {
1511
+ checkFragmentPersistenceConflict(idOrName, input);
1236
1512
  if (!fragments) {
1237
1513
  const empty = { ok: true, refreshed: [], skipped: true };
1238
1514
  if (opts && typeof opts.onSuccess === 'function') {
@@ -1265,14 +1541,34 @@ function createControllerRuntime(options = {}) {
1265
1541
  );
1266
1542
  state = stateManager.state;
1267
1543
 
1268
- // Hydrate persisted state if defined
1544
+ // Hydrate persisted state if defined (with schema-compatible type validation against corrupted storage)
1269
1545
  if (persistConfig) {
1270
1546
  const driver = resolvePersistDriver(persistConfig);
1271
1547
  const savedData = driver.get(persistConfig.storageKey, null);
1272
- if (savedData && typeof savedData === 'object' && !Array.isArray(savedData)) {
1548
+ if (savedData && typeof savedData === 'object' && !isArray(savedData)) {
1273
1549
  for (const [k, v] of Object.entries(savedData)) {
1274
1550
  if (persistConfig.keys && !persistConfig.keys.includes(k)) continue;
1275
- 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) {
1276
1572
  state[k] = v;
1277
1573
  }
1278
1574
  }
@@ -1308,6 +1604,36 @@ function createControllerRuntime(options = {}) {
1308
1604
  if (typeof fn !== 'function') {
1309
1605
  return undefined;
1310
1606
  }
1607
+
1608
+ // Guard against duplicate clicks while an async action on this exact element is already in-flight
1609
+ if (
1610
+ actionEl &&
1611
+ actionEl.__wActionBusy === true &&
1612
+ (!actionEl.hasAttribute || !actionEl.hasAttribute('data-w-allow-concurrent'))
1613
+ ) {
1614
+ if (event && typeof event.preventDefault === 'function') {
1615
+ event.preventDefault();
1616
+ }
1617
+ return undefined;
1618
+ }
1619
+
1620
+ // Built-in `data-w-confirm` guard
1621
+ if (actionEl && typeof actionEl.getAttribute === 'function') {
1622
+ const confirmMsg = actionEl.getAttribute('data-w-confirm');
1623
+ if (confirmMsg !== null && confirmMsg !== '') {
1624
+ const confirmFn =
1625
+ typeof window !== 'undefined' && typeof window.confirm === 'function'
1626
+ ? window.confirm.bind(window)
1627
+ : null;
1628
+ if (confirmFn && !confirmFn(confirmMsg)) {
1629
+ if (event && typeof event.preventDefault === 'function') {
1630
+ event.preventDefault();
1631
+ }
1632
+ return undefined;
1633
+ }
1634
+ }
1635
+ }
1636
+
1311
1637
  const decoratedEl = decorateElement(actionEl);
1312
1638
  currentActionEl = decoratedEl || null;
1313
1639
  const prevContext = activeControllerContext;
@@ -1315,17 +1641,28 @@ function createControllerRuntime(options = {}) {
1315
1641
  try {
1316
1642
  const res = fn(ctx, event, decoratedEl);
1317
1643
  if (res && typeof res.then === 'function') {
1318
- return res.catch((err) => {
1319
- // Ignore canceled/superseded request errors on disconnected or superseded instances
1320
- if (err && err.isCanceled) return undefined;
1321
- handleControllerError(err, {
1322
- action: actionName,
1323
- event,
1324
- actionEl: decoratedEl,
1325
- phase: 'action',
1326
- });
1327
- throw err;
1328
- });
1644
+ setActionBusy(actionName, 1);
1645
+ setElementBusyState(actionEl, true);
1646
+ return res.then(
1647
+ (val) => {
1648
+ setActionBusy(actionName, -1);
1649
+ setElementBusyState(actionEl, false);
1650
+ return val;
1651
+ },
1652
+ (err) => {
1653
+ setActionBusy(actionName, -1);
1654
+ setElementBusyState(actionEl, false);
1655
+ // Ignore canceled/superseded request errors on disconnected or superseded instances
1656
+ if (err && err.isCanceled) return undefined;
1657
+ handleControllerError(err, {
1658
+ action: actionName,
1659
+ event,
1660
+ actionEl: decoratedEl,
1661
+ phase: 'action',
1662
+ });
1663
+ throw err;
1664
+ }
1665
+ );
1329
1666
  }
1330
1667
  return res;
1331
1668
  } catch (err) {
@@ -1353,8 +1690,19 @@ function createControllerRuntime(options = {}) {
1353
1690
  state,
1354
1691
  invokeAction,
1355
1692
  isConnected,
1693
+ isLoadingTargetActive,
1356
1694
  });
1357
- 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
+ }
1358
1706
 
1359
1707
  // Also auto-initialize any `<form data-w-form>` inside this controller's scope
1360
1708
  if (rootEl.tagName === 'FORM' && rootEl.hasAttribute('data-w-form')) {
@@ -1364,7 +1712,10 @@ function createControllerRuntime(options = {}) {
1364
1712
  const formsInScope = rootEl.querySelectorAll('form[data-w-form]');
1365
1713
  for (let i = 0; i < formsInScope.length; i++) {
1366
1714
  if (isTargetInControllerScope(rootEl, formsInScope[i])) {
1367
- createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug });
1715
+ const fh = createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
1716
+ if (fh && fh.name) {
1717
+ scopedForms.set(fh.name, fh);
1718
+ }
1368
1719
  }
1369
1720
  }
1370
1721
  }
@@ -1375,8 +1726,6 @@ function createControllerRuntime(options = {}) {
1375
1726
  // Auto-sync persisted state changes back to storage
1376
1727
  if (persistConfig) {
1377
1728
  const driver = resolvePersistDriver(persistConfig);
1378
- let persistTimer = null;
1379
- let lastSavedJson = '';
1380
1729
 
1381
1730
  const stopPersistEffect = registerEffect(() => {
1382
1731
  if (!isConnected()) return;
@@ -1433,6 +1782,7 @@ function createControllerRuntime(options = {}) {
1433
1782
  name: controllerName,
1434
1783
  el: rootEl,
1435
1784
  state,
1785
+ tasks: tasksProxy,
1436
1786
  ctx,
1437
1787
  invokeAction,
1438
1788
  actions: new Proxy({}, {
@@ -1560,6 +1910,11 @@ function createControllerRuntime(options = {}) {
1560
1910
  if (standaloneForm) {
1561
1911
  standaloneForm.destroy();
1562
1912
  standaloneForms.delete(node);
1913
+ activeStandaloneForms.delete(standaloneForm);
1914
+ } else if (node && node.__wFormHandle) {
1915
+ try {
1916
+ node.__wFormHandle.destroy();
1917
+ } catch (_e) {}
1563
1918
  }
1564
1919
  }
1565
1920
  }
@@ -1642,8 +1997,10 @@ function createControllerRuntime(options = {}) {
1642
1997
  api: defaultApi,
1643
1998
  datastar,
1644
1999
  debug,
2000
+ services,
1645
2001
  });
1646
2002
  standaloneForms.set(formEl, handle);
2003
+ activeStandaloneForms.add(handle);
1647
2004
  }
1648
2005
  }
1649
2006
 
@@ -1666,6 +2023,7 @@ function createControllerRuntime(options = {}) {
1666
2023
 
1667
2024
  return {
1668
2025
  register,
2026
+ services,
1669
2027
  hasController(name) {
1670
2028
  return definitions.has(name);
1671
2029
  },
@@ -1686,10 +2044,19 @@ function createControllerRuntime(options = {}) {
1686
2044
  getActiveInstances() {
1687
2045
  return Array.from(activeInstances.values());
1688
2046
  },
2047
+ getStandaloneFormCount() {
2048
+ return activeStandaloneForms.size;
2049
+ },
1689
2050
  destroyAll() {
1690
2051
  for (const inst of Array.from(activeInstances.values())) {
1691
2052
  inst.destroy();
1692
2053
  }
2054
+ for (const fh of Array.from(activeStandaloneForms.values())) {
2055
+ try {
2056
+ fh.destroy();
2057
+ } catch (_e) {}
2058
+ }
2059
+ activeStandaloneForms.clear();
1693
2060
  },
1694
2061
  };
1695
2062
  }