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.
@@ -84,9 +84,28 @@ class ClientRequestError extends Error {
84
84
  this.status === 408;
85
85
  this.isDropped = Boolean(details.isDropped);
86
86
  this.isSuperseded = Boolean(details.isSuperseded);
87
+ this.dispatched =
88
+ details.dispatched !== undefined
89
+ ? Boolean(details.dispatched)
90
+ : !this.isDropped && (this.kind !== 'canceled' || Boolean(details.cause));
91
+ this.outcomeUnknown =
92
+ details.outcomeUnknown !== undefined
93
+ ? Boolean(details.outcomeUnknown)
94
+ : MUTATION_METHODS.has(this.method) &&
95
+ this.dispatched &&
96
+ !this.isDropped &&
97
+ (this.kind === 'timeout' || this.kind === 'network' || this.kind === 'canceled');
98
+ this.outcome = this.outcomeUnknown
99
+ ? 'unknown'
100
+ : !this.dispatched || this.isDropped
101
+ ? 'not_sent'
102
+ : 'failed';
87
103
  this.serverRollbackGuaranteed = false;
88
104
  if (this.isCanceled) {
89
105
  this.note = 'Client request cancellation/abort does not roll back server-side execution.';
106
+ } else if (this.outcomeUnknown) {
107
+ this.note =
108
+ 'Mutation request was dispatched before response delivery failed; the server may have committed the operation.';
90
109
  }
91
110
  if (details.cause) {
92
111
  this.cause = details.cause;
@@ -599,6 +618,110 @@ function abortableSleep(ms, signals = []) {
599
618
  });
600
619
  }
601
620
 
621
+ function parseXhrHeaders(rawHeaders) {
622
+ const map = new Map();
623
+ if (typeof rawHeaders === 'string' && rawHeaders) {
624
+ const lines = rawHeaders.trim().split(/[\r\n]+/);
625
+ for (let i = 0; i < lines.length; i++) {
626
+ const idx = lines[i].indexOf(':');
627
+ if (idx > 0) {
628
+ const k = lines[i].slice(0, idx).trim().toLowerCase();
629
+ const v = lines[i].slice(idx + 1).trim();
630
+ map.set(k, v);
631
+ }
632
+ }
633
+ }
634
+ return {
635
+ get(name) {
636
+ return map.get(String(name || '').toLowerCase()) ?? null;
637
+ },
638
+ has(name) {
639
+ return map.has(String(name || '').toLowerCase());
640
+ },
641
+ };
642
+ }
643
+
644
+ function executeXhrUpload({ url, method, headers, body, credentials, signal, onUploadProgress }) {
645
+ return new Promise((resolve, reject) => {
646
+ const xhr = new XMLHttpRequest();
647
+ xhr.open(method, url, true);
648
+ if (credentials === 'include' || credentials === 'same-origin') {
649
+ xhr.withCredentials = credentials === 'include';
650
+ }
651
+ if (headers && typeof headers === 'object') {
652
+ for (const [k, v] of Object.entries(headers)) {
653
+ if (v !== undefined && v !== null) {
654
+ xhr.setRequestHeader(k, String(v));
655
+ }
656
+ }
657
+ }
658
+
659
+ if (xhr.upload && typeof onUploadProgress === 'function') {
660
+ xhr.upload.onprogress = (evt) => {
661
+ const lengthComputable = Boolean(evt && evt.lengthComputable);
662
+ const loaded = evt && typeof evt.loaded === 'number' ? evt.loaded : 0;
663
+ const total = lengthComputable && evt && typeof evt.total === 'number' ? evt.total : 0;
664
+ const percent = lengthComputable && total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : null;
665
+ try {
666
+ onUploadProgress({ loaded, total, percent, lengthComputable });
667
+ } catch (_e) {}
668
+ };
669
+ }
670
+
671
+ const onAbort = () => {
672
+ try {
673
+ xhr.abort();
674
+ } catch (_e) {}
675
+ };
676
+ if (signal) {
677
+ if (signal.aborted) {
678
+ const abortErr = new Error('The operation was aborted.');
679
+ abortErr.name = 'AbortError';
680
+ reject(abortErr);
681
+ return;
682
+ }
683
+ signal.addEventListener('abort', onAbort, { once: true });
684
+ }
685
+
686
+ const cleanup = () => {
687
+ if (signal) {
688
+ try {
689
+ signal.removeEventListener('abort', onAbort);
690
+ } catch (_e) {}
691
+ }
692
+ };
693
+
694
+ xhr.onload = () => {
695
+ cleanup();
696
+ const status = xhr.status === 1223 ? 204 : xhr.status;
697
+ const rawText = typeof xhr.responseText === 'string' ? xhr.responseText : '';
698
+ const headerObj = parseXhrHeaders(xhr.getAllResponseHeaders ? xhr.getAllResponseHeaders() : '');
699
+ resolve({
700
+ ok: status >= 200 && status < 300,
701
+ status,
702
+ statusText: xhr.statusText || '',
703
+ headers: headerObj,
704
+ text: async () => rawText,
705
+ json: async () => (rawText ? JSON.parse(rawText) : null),
706
+ });
707
+ };
708
+
709
+ xhr.onerror = () => {
710
+ cleanup();
711
+ reject(new TypeError('Network request failed'));
712
+ };
713
+
714
+ xhr.onabort = () => {
715
+ cleanup();
716
+ const abortErr = new Error('The operation was aborted.');
717
+ abortErr.name = 'AbortError';
718
+ reject(abortErr);
719
+ };
720
+
721
+ xhr.send(body === undefined ? null : body);
722
+ });
723
+ }
724
+
602
725
  /**
603
726
  * Create a Typed API Client instance.
604
727
  *
@@ -675,6 +798,8 @@ function createApiClient(config = {}) {
675
798
  method: upperMethod,
676
799
  url: requestUrl,
677
800
  key: requestKey,
801
+ dispatched: false,
802
+ outcomeUnknown: false,
678
803
  });
679
804
  }
680
805
 
@@ -883,13 +1008,25 @@ function createApiClient(config = {}) {
883
1008
  try {
884
1009
  let response;
885
1010
  try {
886
- response = await fetchImpl(requestUrl, {
887
- method: upperMethod,
888
- headers,
889
- body: serializedBody,
890
- credentials: options.credentials || 'same-origin',
891
- signal: abortController.signal,
892
- });
1011
+ if (typeof options.onUploadProgress === 'function' && typeof XMLHttpRequest !== 'undefined') {
1012
+ response = await executeXhrUpload({
1013
+ url: requestUrl,
1014
+ method: upperMethod,
1015
+ headers,
1016
+ body: serializedBody,
1017
+ credentials: options.credentials || 'same-origin',
1018
+ signal: abortController.signal,
1019
+ onUploadProgress: options.onUploadProgress,
1020
+ });
1021
+ } else {
1022
+ response = await fetchImpl(requestUrl, {
1023
+ method: upperMethod,
1024
+ headers,
1025
+ body: serializedBody,
1026
+ credentials: options.credentials || 'same-origin',
1027
+ signal: abortController.signal,
1028
+ });
1029
+ }
893
1030
  } catch (fetchErr) {
894
1031
  if (timeoutTimer) {
895
1032
  clearTimeout(timeoutTimer);
@@ -914,6 +1051,7 @@ function createApiClient(config = {}) {
914
1051
  url: requestUrl,
915
1052
  key: requestKey,
916
1053
  isTimedOut: true,
1054
+ dispatched: true,
917
1055
  cause: fetchErr,
918
1056
  }
919
1057
  );
@@ -947,6 +1085,7 @@ function createApiClient(config = {}) {
947
1085
  url: requestUrl,
948
1086
  key: requestKey,
949
1087
  isSuperseded: superseded,
1088
+ dispatched: true,
950
1089
  cause: fetchErr,
951
1090
  }
952
1091
  );
@@ -962,6 +1101,7 @@ function createApiClient(config = {}) {
962
1101
  method: upperMethod,
963
1102
  url: requestUrl,
964
1103
  key: requestKey,
1104
+ dispatched: true,
965
1105
  cause: fetchErr,
966
1106
  }
967
1107
  );
@@ -367,6 +367,8 @@ function defineController(definition = {}) {
367
367
  tasks: definition.tasks && typeof definition.tasks === 'object' ? definition.tasks : {},
368
368
  connect: typeof definition.connect === 'function' ? definition.connect : null,
369
369
  disconnect: typeof definition.disconnect === 'function' ? definition.disconnect : null,
370
+ beforePatch: typeof definition.beforePatch === 'function' ? definition.beforePatch : null,
371
+ afterPatch: typeof definition.afterPatch === 'function' ? definition.afterPatch : null,
370
372
  onError: typeof definition.onError === 'function' ? definition.onError : null,
371
373
  persist: definition.persist || null,
372
374
  watch: definition.watch && typeof definition.watch === 'object' ? definition.watch : null,
@@ -495,6 +497,7 @@ function createControllerRuntime(options = {}) {
495
497
  const activeInstances = new Map();
496
498
  // Standalone forms (forms without a controller wrapper)
497
499
  const standaloneForms = new WeakMap();
500
+ const activeStandaloneForms = new Set();
498
501
 
499
502
  /**
500
503
  * Register a controller definition with collision checking.
@@ -737,19 +740,26 @@ function createControllerRuntime(options = {}) {
737
740
  const signalPrefix = `__w_inst_${instanceId}.task_${taskKey}`;
738
741
 
739
742
  // React Query-style automatic revalidation options for tasks
743
+ const isMutationTask = taskOpts.mutation === true || taskOpts.kind === 'mutation';
740
744
  const staleTimeMs = parseDurationMs(taskOpts.staleTime, 0);
741
- const refetchOnMountMode = parseRefetchMode(
742
- taskOpts.refetchOnMount !== undefined ? taskOpts.refetchOnMount : taskOpts.refetchOnVisit,
743
- false
744
- );
745
- const refetchOnFocusMode = parseRefetchMode(
746
- taskOpts.refetchOnWindowFocus !== undefined ? taskOpts.refetchOnWindowFocus : taskOpts.refetchOnFocus,
747
- false
748
- );
749
- const refetchOnReconnectMode = parseRefetchMode(taskOpts.refetchOnReconnect, false);
750
- const refetchIntervalMs = parseDurationMs(taskOpts.refetchInterval, 0);
745
+ const refetchOnMountMode = isMutationTask
746
+ ? false
747
+ : parseRefetchMode(
748
+ taskOpts.refetchOnMount !== undefined ? taskOpts.refetchOnMount : taskOpts.refetchOnVisit,
749
+ false
750
+ );
751
+ const refetchOnFocusMode = isMutationTask
752
+ ? false
753
+ : parseRefetchMode(
754
+ taskOpts.refetchOnWindowFocus !== undefined ? taskOpts.refetchOnWindowFocus : taskOpts.refetchOnFocus,
755
+ false
756
+ );
757
+ const refetchOnReconnectMode = isMutationTask
758
+ ? false
759
+ : parseRefetchMode(taskOpts.refetchOnReconnect, false);
760
+ const refetchIntervalMs = isMutationTask ? 0 : parseDurationMs(taskOpts.refetchInterval, 0);
751
761
  const refetchIntervalInBg =
752
- parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
762
+ !isMutationTask && parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
753
763
 
754
764
  let currentRunVersion = 0;
755
765
  let activeRuns = 0;
@@ -769,6 +779,7 @@ function createControllerRuntime(options = {}) {
769
779
  let _data = null;
770
780
  let _error = null;
771
781
  let _stale = false;
782
+ let _lastArgs = [];
772
783
 
773
784
  function isTaskStale(mode = true) {
774
785
  if (!mode) return false;
@@ -787,6 +798,10 @@ function createControllerRuntime(options = {}) {
787
798
 
788
799
  async function run(...args) {
789
800
  if (!isConnected()) return undefined;
801
+ if (args.length > 0) {
802
+ _lastArgs = [...args];
803
+ }
804
+ const effectiveArgs = args.length > 0 ? args : _lastArgs;
790
805
 
791
806
  if (policy === 'drop' && activeRuns > 0) {
792
807
  if (debug && typeof debug.trackDiscardedResponse === 'function') {
@@ -802,12 +817,12 @@ function createControllerRuntime(options = {}) {
802
817
 
803
818
  if (policy === 'queue') {
804
819
  const wait = queuedPromise;
805
- const nextRun = wait.then(() => executeRun(...args)).catch(() => {});
820
+ const nextRun = wait.then(() => executeRun(...effectiveArgs)).catch(() => {});
806
821
  queuedPromise = nextRun;
807
822
  return nextRun;
808
823
  }
809
824
 
810
- return executeRun(...args);
825
+ return executeRun(...effectiveArgs);
811
826
  }
812
827
 
813
828
  async function executeRun(...args) {
@@ -834,7 +849,7 @@ function createControllerRuntime(options = {}) {
834
849
  try {
835
850
  const result = await runFn(...args);
836
851
 
837
- if (!isConnected() || runVersion !== currentRunVersion) {
852
+ if (!isConnected() || (policy !== 'parallel' && runVersion !== currentRunVersion)) {
838
853
  return undefined;
839
854
  }
840
855
 
@@ -847,7 +862,7 @@ function createControllerRuntime(options = {}) {
847
862
  updateSignal('stale', false);
848
863
  return result;
849
864
  } catch (err) {
850
- if (!isConnected() || runVersion !== currentRunVersion) {
865
+ if (!isConnected() || (policy !== 'parallel' && runVersion !== currentRunVersion)) {
851
866
  return undefined;
852
867
  }
853
868
  if (err && (err.isCanceled || err.name === 'AbortError')) {
@@ -865,7 +880,11 @@ function createControllerRuntime(options = {}) {
865
880
  inFlightAbort = null;
866
881
  }
867
882
  activeRuns = Math.max(0, activeRuns - 1);
868
- if (runVersion === currentRunVersion || activeRuns === 0) {
883
+ const shouldClearPending =
884
+ policy === 'parallel'
885
+ ? activeRuns === 0
886
+ : runVersion === currentRunVersion || activeRuns === 0;
887
+ if (shouldClearPending) {
869
888
  _pending = false;
870
889
  _stale = false;
871
890
  if (isConnected()) {
@@ -912,6 +931,9 @@ function createControllerRuntime(options = {}) {
912
931
  get lastUpdatedAt() {
913
932
  return _lastUpdatedAt;
914
933
  },
934
+ get lastArgs() {
935
+ return [..._lastArgs];
936
+ },
915
937
  isStale: () => isTaskStale(true),
916
938
  run,
917
939
  cancel,
@@ -1359,6 +1381,9 @@ function createControllerRuntime(options = {}) {
1359
1381
  root: defaultStorage,
1360
1382
  });
1361
1383
  const persistConfig = normalizePersistConfig(reg.definition.persist, controllerName);
1384
+ let persistTimer = null;
1385
+ let lastSavedJson = '';
1386
+ const persistenceConflicts = [];
1362
1387
  function resolvePersistDriver(pConfig) {
1363
1388
  if (!pConfig) return storageCtx.local;
1364
1389
  if (pConfig.isGlobal) {
@@ -1367,6 +1392,30 @@ function createControllerRuntime(options = {}) {
1367
1392
  return storageCtx[pConfig.storageType] || storageCtx.local;
1368
1393
  }
1369
1394
 
1395
+ function checkFragmentPersistenceConflict(idOrName, input) {
1396
+ if (!persistConfig || !fragments || typeof fragments.getInstance !== 'function' || !input || typeof input !== 'object') {
1397
+ return;
1398
+ }
1399
+ const inst =
1400
+ fragments.getInstance(idOrName) ||
1401
+ (typeof fragments.getInstances === 'function'
1402
+ ? fragments.getInstances().find((i) => i.name === idOrName)
1403
+ : null);
1404
+ if (inst && inst.persistInput && inst.persistInput.key !== persistConfig.storageKey) {
1405
+ const conflict = {
1406
+ controller: controllerName,
1407
+ controllerStorageKey: persistConfig.storageKey,
1408
+ fragment: inst.instanceId,
1409
+ fragmentStorageKey: inst.persistInput.key,
1410
+ keys: Object.keys(input),
1411
+ };
1412
+ persistenceConflicts.push(conflict);
1413
+ if (debug && typeof debug.recordEvent === 'function') {
1414
+ debug.recordEvent('persist:conflict', conflict);
1415
+ }
1416
+ }
1417
+ }
1418
+
1370
1419
  // 6. Context object passed to `connect(ctx)` and `actions[name](ctx, event, actionEl)`
1371
1420
  ctx = Object.freeze({
1372
1421
  instanceId,
@@ -1383,10 +1432,27 @@ function createControllerRuntime(options = {}) {
1383
1432
  tasks: tasksProxy,
1384
1433
  task: createTaskInstance,
1385
1434
  storage: storageCtx,
1435
+ get persistenceConflicts() {
1436
+ return [...persistenceConflicts];
1437
+ },
1386
1438
  clearPersistedState: () => {
1439
+ if (persistTimer) {
1440
+ clearTimeout(persistTimer);
1441
+ persistTimer = null;
1442
+ }
1387
1443
  if (persistConfig) {
1388
1444
  const driver = resolvePersistDriver(persistConfig);
1389
1445
  driver.remove(persistConfig.storageKey);
1446
+ if (state && initialRawState) {
1447
+ const keysToTrack = persistConfig.keys || Object.keys(initialRawState);
1448
+ const currentPayload = {};
1449
+ for (const k of keysToTrack) {
1450
+ currentPayload[k] = state[k];
1451
+ }
1452
+ lastSavedJson = JSON.stringify(currentPayload);
1453
+ } else {
1454
+ lastSavedJson = '';
1455
+ }
1390
1456
  }
1391
1457
  },
1392
1458
  param: (name, maybeEl) => {
@@ -1430,7 +1496,12 @@ function createControllerRuntime(options = {}) {
1430
1496
  return unregister;
1431
1497
  },
1432
1498
  invalidate: (tags) => (fragments ? fragments.invalidate(tags) : Promise.resolve({ ok: true, refreshed: [] })),
1499
+ retryFailedFragments: () =>
1500
+ fragments && typeof fragments.retryFailed === 'function'
1501
+ ? fragments.retryFailed()
1502
+ : Promise.resolve({ refreshed: [], skipped: true }),
1433
1503
  refresh: async (idOrName, input, opts) => {
1504
+ checkFragmentPersistenceConflict(idOrName, input);
1434
1505
  if (!fragments) {
1435
1506
  const empty = { ok: true, refreshed: [], skipped: true };
1436
1507
  if (opts && typeof opts.onSuccess === 'function') await opts.onSuccess(empty);
@@ -1445,7 +1516,17 @@ function createControllerRuntime(options = {}) {
1445
1516
  const inst = fragments && fragments.getInstance ? fragments.getInstance(idOrName) : null;
1446
1517
  return inst && inst.input ? { ...inst.input } : {};
1447
1518
  },
1519
+ get inputs() {
1520
+ if (!fragments || typeof fragments.getInstances !== 'function') return {};
1521
+ const list = fragments.getInstances(idOrName);
1522
+ const map = {};
1523
+ for (const inst of list) {
1524
+ map[inst.instanceId] = inst.input ? { ...inst.input } : {};
1525
+ }
1526
+ return map;
1527
+ },
1448
1528
  refresh: (input, opts) => {
1529
+ checkFragmentPersistenceConflict(idOrName, input);
1449
1530
  if (!fragments) {
1450
1531
  const empty = { ok: true, refreshed: [], skipped: true };
1451
1532
  if (opts && typeof opts.onSuccess === 'function') {
@@ -1478,14 +1559,34 @@ function createControllerRuntime(options = {}) {
1478
1559
  );
1479
1560
  state = stateManager.state;
1480
1561
 
1481
- // Hydrate persisted state if defined
1562
+ // Hydrate persisted state if defined (with schema-compatible type validation against corrupted storage)
1482
1563
  if (persistConfig) {
1483
1564
  const driver = resolvePersistDriver(persistConfig);
1484
1565
  const savedData = driver.get(persistConfig.storageKey, null);
1485
1566
  if (savedData && typeof savedData === 'object' && !isArray(savedData)) {
1486
1567
  for (const [k, v] of Object.entries(savedData)) {
1487
1568
  if (persistConfig.keys && !persistConfig.keys.includes(k)) continue;
1488
- if (Object.prototype.hasOwnProperty.call(initialRawState, k)) {
1569
+ if (!Object.prototype.hasOwnProperty.call(initialRawState, k)) continue;
1570
+ const initVal = initialRawState[k];
1571
+ if (initVal !== null && typeof initVal === 'object' && !isArray(initVal)) {
1572
+ if (v !== null && typeof v === 'object' && !isArray(v)) {
1573
+ const merged = { ...initVal };
1574
+ for (const [subKey, subVal] of Object.entries(v)) {
1575
+ if (Object.prototype.hasOwnProperty.call(initVal, subKey)) {
1576
+ if (initVal[subKey] === null || typeof subVal === typeof initVal[subKey]) {
1577
+ merged[subKey] = subVal;
1578
+ }
1579
+ } else {
1580
+ merged[subKey] = subVal;
1581
+ }
1582
+ }
1583
+ state[k] = merged;
1584
+ }
1585
+ } else if (isArray(initVal)) {
1586
+ if (isArray(v)) {
1587
+ state[k] = v;
1588
+ }
1589
+ } else if (initVal === null || initVal === undefined || typeof v === typeof initVal) {
1489
1590
  state[k] = v;
1490
1591
  }
1491
1592
  }
@@ -1609,7 +1710,17 @@ function createControllerRuntime(options = {}) {
1609
1710
  isConnected,
1610
1711
  isLoadingTargetActive,
1611
1712
  });
1612
- domDisposers.push(unbind);
1713
+ if (domDisposers.length === 0) {
1714
+ domDisposers.push(unbind);
1715
+ }
1716
+
1717
+ // Prune disconnected forms from scopedForms
1718
+ for (const [fName, fh] of Array.from(scopedForms.entries())) {
1719
+ if (!fh.el || fh.el.isConnected === false || !rootEl.contains(fh.el)) {
1720
+ fh.destroy();
1721
+ scopedForms.delete(fName);
1722
+ }
1723
+ }
1613
1724
 
1614
1725
  // Also auto-initialize any `<form data-w-form>` inside this controller's scope
1615
1726
  if (rootEl.tagName === 'FORM' && rootEl.hasAttribute('data-w-form')) {
@@ -1619,7 +1730,10 @@ function createControllerRuntime(options = {}) {
1619
1730
  const formsInScope = rootEl.querySelectorAll('form[data-w-form]');
1620
1731
  for (let i = 0; i < formsInScope.length; i++) {
1621
1732
  if (isTargetInControllerScope(rootEl, formsInScope[i])) {
1622
- createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
1733
+ const fh = createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
1734
+ if (fh && fh.name) {
1735
+ scopedForms.set(fh.name, fh);
1736
+ }
1623
1737
  }
1624
1738
  }
1625
1739
  }
@@ -1630,8 +1744,6 @@ function createControllerRuntime(options = {}) {
1630
1744
  // Auto-sync persisted state changes back to storage
1631
1745
  if (persistConfig) {
1632
1746
  const driver = resolvePersistDriver(persistConfig);
1633
- let persistTimer = null;
1634
- let lastSavedJson = '';
1635
1747
 
1636
1748
  const stopPersistEffect = registerEffect(() => {
1637
1749
  if (!isConnected()) return;
@@ -1703,6 +1815,33 @@ function createControllerRuntime(options = {}) {
1703
1815
  get isConnected() {
1704
1816
  return isConnected();
1705
1817
  },
1818
+ invokeBeforePatch(meta = {}) {
1819
+ if (!isConnected() || !reg.definition.beforePatch) return;
1820
+ try {
1821
+ const result = reg.definition.beforePatch(ctx, meta);
1822
+ if (result && typeof result.then === 'function') {
1823
+ // beforePatch must be synchronous; warn in dev and do not block morph
1824
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
1825
+ console.warn(`[webspresso] Controller "${controllerName}" beforePatch() returned a Promise; beforePatch hooks are synchronous and are not awaited.`);
1826
+ }
1827
+ }
1828
+ } catch (err) {
1829
+ handleControllerError(err, { phase: 'beforePatch' });
1830
+ }
1831
+ },
1832
+ invokeAfterPatch(meta = {}) {
1833
+ if (!isConnected() || !reg.definition.afterPatch) return;
1834
+ try {
1835
+ const res = reg.definition.afterPatch(ctx, meta);
1836
+ if (res && typeof res.then === 'function') {
1837
+ res.catch((err) => {
1838
+ handleControllerError(err, { phase: 'afterPatch' });
1839
+ });
1840
+ }
1841
+ } catch (err) {
1842
+ handleControllerError(err, { phase: 'afterPatch' });
1843
+ }
1844
+ },
1706
1845
  destroy() {
1707
1846
  if (!connected) return;
1708
1847
  connected = false;
@@ -1816,6 +1955,11 @@ function createControllerRuntime(options = {}) {
1816
1955
  if (standaloneForm) {
1817
1956
  standaloneForm.destroy();
1818
1957
  standaloneForms.delete(node);
1958
+ activeStandaloneForms.delete(standaloneForm);
1959
+ } else if (node && node.__wFormHandle) {
1960
+ try {
1961
+ node.__wFormHandle.destroy();
1962
+ } catch (_e) {}
1819
1963
  }
1820
1964
  }
1821
1965
  }
@@ -1901,6 +2045,7 @@ function createControllerRuntime(options = {}) {
1901
2045
  services,
1902
2046
  });
1903
2047
  standaloneForms.set(formEl, handle);
2048
+ activeStandaloneForms.add(handle);
1904
2049
  }
1905
2050
  }
1906
2051
 
@@ -1921,6 +2066,82 @@ function createControllerRuntime(options = {}) {
1921
2066
  return mounted;
1922
2067
  }
1923
2068
 
2069
+ const patchCycleTracker = typeof WeakMap !== 'undefined' ? new WeakMap() : null;
2070
+
2071
+ function collectAffectedInstances(fragmentEl) {
2072
+ if (!fragmentEl) return [];
2073
+ const affected = [];
2074
+ for (const inst of activeInstances.values()) {
2075
+ if (!inst || !inst.el || !inst.isConnected) continue;
2076
+ if (
2077
+ inst.el === fragmentEl ||
2078
+ (typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el)) ||
2079
+ (typeof inst.el.contains === 'function' && inst.el.contains(fragmentEl))
2080
+ ) {
2081
+ affected.push(inst);
2082
+ }
2083
+ }
2084
+ // Sort deterministically: outer ancestors first, inner descendants second
2085
+ affected.sort((a, b) => {
2086
+ if (a.el === b.el) return 0;
2087
+ if (typeof a.el.contains === 'function' && a.el.contains(b.el)) return -1;
2088
+ if (typeof b.el.contains === 'function' && b.el.contains(a.el)) return 1;
2089
+ if (typeof a.el.compareDocumentPosition === 'function') {
2090
+ const pos = a.el.compareDocumentPosition(b.el);
2091
+ if (pos & 4) return -1; // DOCUMENT_POSITION_FOLLOWING
2092
+ if (pos & 2) return 1; // DOCUMENT_POSITION_PRECEDING
2093
+ }
2094
+ return 0;
2095
+ });
2096
+ return affected;
2097
+ }
2098
+
2099
+ function notifyBeforePatch(fragmentEl, meta = {}) {
2100
+ const affected = collectAffectedInstances(fragmentEl);
2101
+ for (let i = 0; i < affected.length; i++) {
2102
+ const inst = affected[i];
2103
+ inst.invokeBeforePatch({
2104
+ ...meta,
2105
+ isFragmentRoot: inst.el === fragmentEl,
2106
+ isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
2107
+ });
2108
+ }
2109
+ }
2110
+
2111
+ function notifyAfterPatch(fragmentEl, meta = {}) {
2112
+ if (patchCycleTracker && fragmentEl) {
2113
+ const now = Date.now();
2114
+ const prev = patchCycleTracker.get(fragmentEl) || { count: 0, lastAt: 0 };
2115
+ const count = now - prev.lastAt < 100 ? prev.count + 1 : 1;
2116
+ patchCycleTracker.set(fragmentEl, { count, lastAt: now });
2117
+ if (count > 5) {
2118
+ const msg = `Possible patch hook refresh loop detected on fragment "${meta.fragmentName || meta.fragmentId || 'unknown'}" (${count} rapid patch cycles).`;
2119
+ if (debug && typeof debug.recordEvent === 'function') {
2120
+ debug.recordEvent('patch:loop-warning', {
2121
+ code: 'PATCH_HOOK_LOOP_DETECTED',
2122
+ fragmentId: meta.fragmentId || null,
2123
+ fragmentName: meta.fragmentName || null,
2124
+ count,
2125
+ message: msg,
2126
+ });
2127
+ } else if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production' && typeof console !== 'undefined') {
2128
+ console.warn(`[webspresso] ${msg}`);
2129
+ }
2130
+ }
2131
+ }
2132
+
2133
+ const affected = collectAffectedInstances(fragmentEl);
2134
+ // Reverse order for afterPatch: inner descendants first, outer ancestors second
2135
+ for (let i = affected.length - 1; i >= 0; i--) {
2136
+ const inst = affected[i];
2137
+ inst.invokeAfterPatch({
2138
+ ...meta,
2139
+ isFragmentRoot: inst.el === fragmentEl,
2140
+ isDescendant: inst.el !== fragmentEl && typeof fragmentEl.contains === 'function' && fragmentEl.contains(inst.el),
2141
+ });
2142
+ }
2143
+ }
2144
+
1924
2145
  return {
1925
2146
  register,
1926
2147
  services,
@@ -1934,6 +2155,8 @@ function createControllerRuntime(options = {}) {
1934
2155
  mountSubtree,
1935
2156
  syncTree: mountSubtree,
1936
2157
  unmountSubtree,
2158
+ notifyBeforePatch,
2159
+ notifyAfterPatch,
1937
2160
  mountControllerOnElement,
1938
2161
  getInstanceForElement(el, name) {
1939
2162
  const map = elementInstances.get(el);
@@ -1944,10 +2167,19 @@ function createControllerRuntime(options = {}) {
1944
2167
  getActiveInstances() {
1945
2168
  return Array.from(activeInstances.values());
1946
2169
  },
2170
+ getStandaloneFormCount() {
2171
+ return activeStandaloneForms.size;
2172
+ },
1947
2173
  destroyAll() {
1948
2174
  for (const inst of Array.from(activeInstances.values())) {
1949
2175
  inst.destroy();
1950
2176
  }
2177
+ for (const fh of Array.from(activeStandaloneForms.values())) {
2178
+ try {
2179
+ fh.destroy();
2180
+ } catch (_e) {}
2181
+ }
2182
+ activeStandaloneForms.clear();
1951
2183
  },
1952
2184
  };
1953
2185
  }