webspresso 0.0.109 → 0.0.111

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.
@@ -17,8 +17,211 @@
17
17
 
18
18
  const { createFormHandle } = require('./forms');
19
19
  const { createApiClient } = require('./api');
20
+ const { defaultStorage, createClientStorage } = require('./storage');
21
+ const { createClientServiceRegistry } = require('./services');
22
+ const {
23
+ isArray,
24
+ isNumber,
25
+ decorateElement,
26
+ parseDurationMs,
27
+ parseRefetchMode,
28
+ setElementBusyState,
29
+ } = require('./utils');
20
30
 
21
31
  let instanceSeq = 0;
32
+ let activeControllerContext = null;
33
+
34
+ function getActiveControllerContext() {
35
+ return activeControllerContext;
36
+ }
37
+
38
+ function resolveStatePath(root, pathStr) {
39
+ if (!root || !pathStr) return undefined;
40
+ const parts = String(pathStr).split('.');
41
+ let cur = root;
42
+ for (let i = 0; i < parts.length; i++) {
43
+ if (cur === null || typeof cur !== 'object') return undefined;
44
+ cur = cur[parts[i]];
45
+ }
46
+ return cur;
47
+ }
48
+
49
+ function deepTouch(val, visited = new Set()) {
50
+ if (val === null || typeof val !== 'object' || visited.has(val)) {
51
+ return;
52
+ }
53
+ visited.add(val);
54
+ if (isArray(val)) {
55
+ for (let i = 0; i < val.length; i++) {
56
+ deepTouch(val[i], visited);
57
+ }
58
+ } else {
59
+ const keys = Object.keys(val);
60
+ for (let i = 0; i < keys.length; i++) {
61
+ deepTouch(val[keys[i]], visited);
62
+ }
63
+ }
64
+ }
65
+
66
+ function deepCloneOrSnapshot(val) {
67
+ if (val === null || typeof val !== 'object') return val;
68
+ try {
69
+ return JSON.parse(JSON.stringify(val));
70
+ } catch {
71
+ return isArray(val) ? [...val] : { ...val };
72
+ }
73
+ }
74
+
75
+ function deepEqual(a, b) {
76
+ if (Object.is(a, b)) return true;
77
+ if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') return false;
78
+ if (isArray(a) !== isArray(b)) return false;
79
+ const keysA = Object.keys(a);
80
+ const keysB = Object.keys(b);
81
+ if (keysA.length !== keysB.length) return false;
82
+ for (let i = 0; i < keysA.length; i++) {
83
+ const k = keysA[i];
84
+ if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEqual(a[k], b[k])) {
85
+ return false;
86
+ }
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function hasArrayChanged(newArr, oldArr) {
92
+ if (!isArray(newArr) || !isArray(oldArr)) return !Object.is(newArr, oldArr);
93
+ if (newArr.length !== oldArr.length) return true;
94
+ for (let i = 0; i < newArr.length; i++) {
95
+ if (!Object.is(newArr[i], oldArr[i])) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ function runUntracked(engine, fn) {
101
+ if (engine && typeof engine.untracked === 'function') {
102
+ return engine.untracked(fn);
103
+ }
104
+ return fn();
105
+ }
106
+
107
+ function createStandaloneWatch(engine, source, callback, options = {}) {
108
+ if (typeof callback !== 'function') {
109
+ throw new TypeError('watch(source, callback) requires callback to be a function.');
110
+ }
111
+
112
+ const immediate = Boolean(options.immediate);
113
+ const deep = Boolean(options.deep);
114
+ const once = Boolean(options.once);
115
+
116
+ let getter;
117
+ if (typeof source === 'function') {
118
+ getter = source;
119
+ } else if (isArray(source)) {
120
+ getter = () => source.map((s) => (typeof s === 'function' ? s() : s));
121
+ } else {
122
+ getter = () => source;
123
+ }
124
+
125
+ let oldValue = undefined;
126
+ let isFirstRun = true;
127
+ let stopped = false;
128
+ let userCleanup = null;
129
+
130
+ function runCleanup() {
131
+ if (typeof userCleanup === 'function') {
132
+ try {
133
+ userCleanup();
134
+ } catch (_e) {}
135
+ userCleanup = null;
136
+ }
137
+ }
138
+
139
+ function onCleanup(fn) {
140
+ if (typeof fn === 'function') {
141
+ userCleanup = fn;
142
+ }
143
+ }
144
+
145
+ function stopWatch() {
146
+ if (stopped) return;
147
+ stopped = true;
148
+ runCleanup();
149
+ stopEffect();
150
+ }
151
+
152
+ const stopEffect = engine.effect(() => {
153
+ if (stopped) return;
154
+
155
+ let newValue;
156
+ try {
157
+ newValue = getter();
158
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !isArray(source))) && newValue && typeof newValue === 'object') {
159
+ deepTouch(newValue);
160
+ }
161
+ } catch (_err) {
162
+ return;
163
+ }
164
+
165
+ const isArr = isArray(source);
166
+
167
+ if (isFirstRun) {
168
+ isFirstRun = false;
169
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
170
+ if (immediate) {
171
+ runCleanup();
172
+ runUntracked(engine, () => {
173
+ callback(newValue, undefined, onCleanup);
174
+ });
175
+ if (once) stopWatch();
176
+ }
177
+ return;
178
+ }
179
+
180
+ const hasChanged = deep
181
+ ? !deepEqual(newValue, oldValue)
182
+ : isArr
183
+ ? hasArrayChanged(newValue, oldValue)
184
+ : !Object.is(newValue, oldValue);
185
+
186
+ if (!hasChanged) return;
187
+
188
+ const prev = oldValue;
189
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
190
+
191
+ runCleanup();
192
+ runUntracked(engine, () => {
193
+ callback(newValue, prev, onCleanup);
194
+ });
195
+
196
+ if (once) stopWatch();
197
+ });
198
+
199
+ return stopWatch;
200
+ }
201
+
202
+ /**
203
+ * Vue-style reactive watcher.
204
+ *
205
+ * @template T
206
+ * @param {(() => T) | string | Array<any>} source - Reactive getter function, state dot-path, or array of sources
207
+ * @param {(newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void} callback - Handler invoked when value changes
208
+ * @param {object} [options]
209
+ * @param {boolean} [options.immediate=false] - Trigger callback immediately on registration
210
+ * @param {boolean} [options.deep=false] - Deep watch nested object/array mutations
211
+ * @param {boolean} [options.once=false] - Stop watching automatically after first trigger
212
+ * @returns {() => void} Unwatch function
213
+ */
214
+ function watch(source, callback, options = {}) {
215
+ if (activeControllerContext && typeof activeControllerContext.watch === 'function') {
216
+ return activeControllerContext.watch(source, callback, options);
217
+ }
218
+ const { createDatastarAdapter } = require('./adapter/datastar');
219
+ const engine = createDatastarAdapter().engine;
220
+ if (!engine || typeof engine.effect !== 'function') {
221
+ return () => {};
222
+ }
223
+ return createStandaloneWatch(engine, source, callback, options);
224
+ }
22
225
 
23
226
  /**
24
227
  * Derive a deterministic controller name from a `.client.{js,ts}` file path.
@@ -33,34 +236,67 @@ let instanceSeq = 0;
33
236
  * @param {string} filePath
34
237
  * @returns {string}
35
238
  */
239
+ function stripBrackets(str) {
240
+ return str.replace(/[\[\]]/g, '');
241
+ }
242
+
243
+ function extractAfterMarker(path, marker) {
244
+ const candidates = ['/src/' + marker, 'src/' + marker, '/' + marker, marker];
245
+ for (const cand of candidates) {
246
+ const idx = path.lastIndexOf(cand);
247
+ if (idx !== -1) {
248
+ if (cand.startsWith('/') || idx === 0) {
249
+ return path.slice(idx + cand.length);
250
+ }
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function stripClientExtension(path) {
257
+ const exts = ['.client.ts', '.client.tsx', '.client.js', '.client.jsx', '.client.mjs', '.client.cjs'];
258
+ const lower = path.toLowerCase();
259
+ for (const ext of exts) {
260
+ if (lower.endsWith(ext)) {
261
+ return path.slice(0, -ext.length);
262
+ }
263
+ }
264
+ return path;
265
+ }
266
+
36
267
  function deriveControllerNameFromPath(filePath) {
37
268
  if (!filePath || typeof filePath !== 'string') return '';
38
269
  const normalized = filePath.replace(/\\/g, '/');
270
+ const withoutExt = stripClientExtension(normalized);
39
271
 
40
- // Strip extension (.client.ts, .client.js, .client.mjs, etc.)
41
- const withoutExt = normalized.replace(/\.client\.(?:ts|tsx|js|jsx|mjs|cjs)$/i, '');
42
-
43
- // Check if under views/components/ or views/
44
- const compMatch = withoutExt.match(/(?:^|\/)(?:src\/)?views\/components\/(.+)$/);
45
- if (compMatch) {
46
- return compMatch[1].replace(/\//g, '--').replace(/\[([^\]]+)\]/g, '$1');
272
+ // Check if under views/components/
273
+ const compRel = extractAfterMarker(withoutExt, 'views/components/');
274
+ if (compRel) {
275
+ return stripBrackets(compRel.split('/').join('--'));
47
276
  }
48
277
 
49
- const viewsMatch = withoutExt.match(/(?:^|\/)(?:src\/)?views\/(.+)$/);
50
- if (viewsMatch) {
51
- const rel = viewsMatch[1];
52
- const base = rel.split('/').pop();
53
- return (base || rel).replace(/\[([^\]]+)\]/g, '$1');
278
+ // Check if under views/
279
+ const viewsRel = extractAfterMarker(withoutExt, 'views/');
280
+ if (viewsRel) {
281
+ const base = viewsRel.split('/').pop() || viewsRel;
282
+ return stripBrackets(base);
54
283
  }
55
284
 
56
- const pagesMatch = withoutExt.match(/(?:^|\/)(?:src\/)?pages\/(.+)$/);
57
- if (pagesMatch) {
58
- const rel = pagesMatch[1].replace(/\/index$/, '') || 'index';
59
- return rel.replace(/\//g, '--').replace(/\[([^\]]+)\]/g, '$1');
285
+ // Check if under pages/
286
+ const pagesRel = extractAfterMarker(withoutExt, 'pages/');
287
+ if (pagesRel) {
288
+ let rel = pagesRel;
289
+ if (rel.endsWith('/index')) {
290
+ rel = rel.slice(0, -6);
291
+ }
292
+ if (!rel || rel === 'index') {
293
+ rel = 'index';
294
+ }
295
+ return stripBrackets(rel.split('/').join('--'));
60
296
  }
61
297
 
62
298
  const base = withoutExt.split('/').pop() || withoutExt;
63
- return base.replace(/\[([^\]]+)\]/g, '$1');
299
+ return stripBrackets(base);
64
300
  }
65
301
 
66
302
  /**
@@ -89,6 +325,23 @@ function defineController(definition = {}) {
89
325
  if (definition.tasks !== undefined && (typeof definition.tasks !== 'object' || definition.tasks === null)) {
90
326
  throw new TypeError('defineController({ tasks }) must be an object of task factory functions.');
91
327
  }
328
+ if (definition.watch !== undefined && (typeof definition.watch !== 'object' || definition.watch === null)) {
329
+ throw new TypeError('defineController({ watch }) must be an object of watcher functions or descriptors.');
330
+ }
331
+ if (definition.onError !== undefined && typeof definition.onError !== 'function') {
332
+ throw new TypeError('defineController({ onError }) must be an error handler function `(err, ctx, meta) => void`.');
333
+ }
334
+ if (definition.persist !== undefined && definition.persist !== null) {
335
+ const p = definition.persist;
336
+ const isArr = isArray(p);
337
+ const isStr = typeof p === 'string';
338
+ const isObj = typeof p === 'object' && p !== null && !isArr;
339
+ if (!isArr && !isStr && !isObj) {
340
+ throw new TypeError(
341
+ 'defineController({ persist }) must be an array of keys, storage type string ("local" | "session"), or a config object { keys?, storage?, key?, debounce? }.'
342
+ );
343
+ }
344
+ }
92
345
 
93
346
  // Pre-validate collision between state and computed properties
94
347
  if (definition.computed && typeof definition.computed === 'object') {
@@ -114,10 +367,53 @@ function defineController(definition = {}) {
114
367
  tasks: definition.tasks && typeof definition.tasks === 'object' ? definition.tasks : {},
115
368
  connect: typeof definition.connect === 'function' ? definition.connect : null,
116
369
  disconnect: typeof definition.disconnect === 'function' ? definition.disconnect : null,
370
+ onError: typeof definition.onError === 'function' ? definition.onError : null,
371
+ persist: definition.persist || null,
372
+ watch: definition.watch && typeof definition.watch === 'object' ? definition.watch : null,
117
373
  actions: definition.actions && typeof definition.actions === 'object' ? definition.actions : {},
118
374
  });
119
375
  }
120
376
 
377
+ /**
378
+ * Normalize controller persist configuration.
379
+ *
380
+ * @param {any} persistOpt
381
+ * @param {string|null} controllerName
382
+ * @returns {{ keys: string[]|null, storageType: 'local'|'session'|'memory', storageKey: string, debounce: number }|null}
383
+ */
384
+ function normalizePersistConfig(persistOpt, controllerName) {
385
+ if (!persistOpt) return null;
386
+ let keys = null;
387
+ let storageType = 'local';
388
+ let storageKey = 'state';
389
+ let isGlobal = false;
390
+ let debounce = 50;
391
+
392
+ if (isArray(persistOpt)) {
393
+ keys = persistOpt.map(String);
394
+ } else if (typeof persistOpt === 'string') {
395
+ const s = persistOpt.trim().toLowerCase();
396
+ storageType = s === 'session' ? 'session' : 'local';
397
+ } else if (typeof persistOpt === 'object' && persistOpt !== null) {
398
+ if (isArray(persistOpt.keys)) {
399
+ keys = persistOpt.keys.map(String);
400
+ }
401
+ if (persistOpt.storage === 'session' || persistOpt.storage === 'memory') {
402
+ storageType = persistOpt.storage;
403
+ }
404
+ if (typeof persistOpt.key === 'string' && persistOpt.key.trim()) {
405
+ storageKey = persistOpt.key.trim();
406
+ }
407
+ if (Boolean(persistOpt.global)) {
408
+ isGlobal = true;
409
+ }
410
+ if (isNumber(persistOpt.debounce)) {
411
+ debounce = Math.max(0, persistOpt.debounce);
412
+ }
413
+ }
414
+ return { keys, storageType, storageKey, isGlobal, debounce };
415
+ }
416
+
121
417
  /**
122
418
  * Check if `candidateEl` belongs to `rootEl`'s controller scope for `controllerName`,
123
419
  * and is NOT inside a nested `[data-w-controller]` element.
@@ -159,7 +455,7 @@ function queryScopedTargets(rootEl, controllerName, targetName) {
159
455
  const rawAttr = (el.getAttribute('data-w-target') || '').trim();
160
456
  const tokens = rawAttr.split(/\s+/);
161
457
  if (tokens.includes(cleanTarget) || tokens.includes(prefixedTarget)) {
162
- matched.push(el);
458
+ matched.push(decorateElement(el));
163
459
  }
164
460
  }
165
461
 
@@ -182,6 +478,14 @@ function createControllerRuntime(options = {}) {
182
478
  const debug = options.debug || null;
183
479
  const baseApiConfig = options.apiConfig || {};
184
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
+ });
185
489
 
186
490
  // Registered controller definitions: name -> { name, definition, sourcePath }
187
491
  const definitions = new Map();
@@ -201,11 +505,18 @@ function createControllerRuntime(options = {}) {
201
505
  * @param {string} [meta.sourcePath]
202
506
  * @param {string[]} [meta.aliases]
203
507
  */
204
- function register(name, rawDef, meta = {}) {
508
+ function register(nameOrDef, rawDef, meta = {}) {
509
+ let name = nameOrDef;
510
+ let actualDef = rawDef;
511
+ if (typeof nameOrDef === 'object' && nameOrDef !== null && !rawDef) {
512
+ actualDef = nameOrDef;
513
+ name = actualDef.name || (actualDef.default && actualDef.default.name) || null;
514
+ }
515
+
205
516
  const def =
206
- rawDef && rawDef.__isWebspressoController
207
- ? rawDef
208
- : defineController(rawDef && rawDef.default ? rawDef.default : rawDef);
517
+ actualDef && actualDef.__isWebspressoController
518
+ ? actualDef
519
+ : defineController(actualDef && actualDef.default ? actualDef.default : actualDef);
209
520
 
210
521
  const canonicalName = String(name || def.name || '').trim();
211
522
  if (!canonicalName) {
@@ -239,7 +550,7 @@ function createControllerRuntime(options = {}) {
239
550
  };
240
551
  definitions.set(canonicalName, entry);
241
552
 
242
- if (Array.isArray(meta.aliases)) {
553
+ if (isArray(meta.aliases)) {
243
554
  for (const alias of meta.aliases) {
244
555
  if (alias && !definitions.has(alias)) {
245
556
  definitions.set(alias, entry);
@@ -292,7 +603,122 @@ function createControllerRuntime(options = {}) {
292
603
  const scopedForms = new Map();
293
604
  const scopedTasks = {};
294
605
  const scopedTasksList = [];
606
+ const busyActionCounts = new Map();
607
+ let totalBusyActions = 0;
608
+ const busySignalPrefix = `__w_inst_${instanceId}.__busy`;
609
+ datastar.engine.signals.setValue(`${busySignalPrefix}.any`, false);
610
+
611
+ function syncAnyBusySignal() {
612
+ let busy = totalBusyActions > 0;
613
+ if (!busy) {
614
+ for (let i = 0; i < scopedTasksList.length; i++) {
615
+ if (scopedTasksList[i].pending) {
616
+ busy = true;
617
+ break;
618
+ }
619
+ }
620
+ }
621
+ datastar.engine.signals.setValue(`${busySignalPrefix}.any`, busy);
622
+ }
623
+
624
+ function setActionBusy(actionName, delta) {
625
+ const prevCount = busyActionCounts.get(actionName) || 0;
626
+ const nextCount = Math.max(0, prevCount + delta);
627
+ if (nextCount === 0) {
628
+ busyActionCounts.delete(actionName);
629
+ } else {
630
+ busyActionCounts.set(actionName, nextCount);
631
+ }
632
+ totalBusyActions = Math.max(0, totalBusyActions + delta);
633
+ datastar.engine.signals.setValue(`${busySignalPrefix}.action_${actionName}`, nextCount > 0);
634
+ syncAnyBusySignal();
635
+ }
636
+
637
+ function isActionBusy(actionName) {
638
+ if (!actionName) return false;
639
+ datastar.engine.signals.value(`${busySignalPrefix}.action_${actionName}`);
640
+ return (busyActionCounts.get(actionName) || 0) > 0;
641
+ }
642
+
643
+ function checkIsBusy() {
644
+ datastar.engine.signals.value(`${busySignalPrefix}.any`);
645
+ if (totalBusyActions > 0) return true;
646
+ for (let i = 0; i < scopedTasksList.length; i++) {
647
+ if (scopedTasksList[i].pending) return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ function isLoadingTargetActive(rawSpec) {
653
+ const spec = typeof rawSpec === 'string' ? rawSpec.trim() : '';
654
+ if (!spec || spec === 'true') {
655
+ return checkIsBusy();
656
+ }
657
+ const parts = spec.split(',').map((s) => s.trim()).filter(Boolean);
658
+ for (let i = 0; i < parts.length; i++) {
659
+ const token = parts[i];
660
+ if (reg.definition.actions && Object.prototype.hasOwnProperty.call(reg.definition.actions, token)) {
661
+ if (isActionBusy(token)) return true;
662
+ continue;
663
+ }
664
+ if (
665
+ Object.prototype.hasOwnProperty.call(scopedTasks, token) ||
666
+ (reg.definition.tasks && Object.prototype.hasOwnProperty.call(reg.definition.tasks, token))
667
+ ) {
668
+ const t = getTask(token);
669
+ if (t && t.pending) return true;
670
+ continue;
671
+ }
672
+ if (Boolean(datastar.evaluateStateExpression(state, token))) {
673
+ return true;
674
+ }
675
+ }
676
+ return false;
677
+ }
678
+
295
679
  let currentActionEl = null;
680
+ let lastError = null;
681
+ const dynamicErrorListeners = new Set();
682
+
683
+ function handleControllerError(err, meta = {}) {
684
+ if (!err || err.isCanceled) {
685
+ return;
686
+ }
687
+
688
+ lastError = err;
689
+
690
+ // 1. Controller definition onError hook
691
+ if (typeof reg.definition.onError === 'function') {
692
+ try {
693
+ reg.definition.onError(err, ctx, meta);
694
+ } catch (hookErr) {
695
+ if (debug) {
696
+ debug.trackCleanupError(instanceId, controllerName, 'onError', hookErr);
697
+ }
698
+ }
699
+ }
700
+
701
+ // 2. Dynamic ctx.onError listeners
702
+ for (const listener of Array.from(dynamicErrorListeners)) {
703
+ try {
704
+ listener(err, meta);
705
+ } catch (_listenerErr) {
706
+ // Ignore
707
+ }
708
+ }
709
+
710
+ // 3. Dispatch bubbling CustomEvent 'w-controller:error' on rootEl
711
+ emitEvent(
712
+ 'w-controller:error',
713
+ {
714
+ error: err,
715
+ instanceId,
716
+ controllerName,
717
+ ...meta,
718
+ },
719
+ { bubbles: true, cancelable: true }
720
+ );
721
+ }
296
722
 
297
723
  let taskSeq = 0;
298
724
  function createTaskInstance(runFnOrConfig, options = {}) {
@@ -310,10 +736,29 @@ function createControllerRuntime(options = {}) {
310
736
  const taskKey = taskOpts.key || `task_${++taskSeq}`;
311
737
  const signalPrefix = `__w_inst_${instanceId}.task_${taskKey}`;
312
738
 
739
+ // React Query-style automatic revalidation options for tasks
740
+ 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);
751
+ const refetchIntervalInBg =
752
+ parseRefetchMode(taskOpts.refetchIntervalInBackground, false) === true;
753
+
313
754
  let currentRunVersion = 0;
314
755
  let activeRuns = 0;
315
756
  let inFlightAbort = null;
316
757
  let queuedPromise = Promise.resolve();
758
+ let _lastUpdatedAt = 0;
759
+ let taskIntervalTimer = null;
760
+ let focusDebounceTimer = null;
761
+ const taskCleanupListeners = [];
317
762
 
318
763
  datastar.engine.signals.setValue(`${signalPrefix}.pending`, false);
319
764
  datastar.engine.signals.setValue(`${signalPrefix}.data`, null);
@@ -325,8 +770,19 @@ function createControllerRuntime(options = {}) {
325
770
  let _error = null;
326
771
  let _stale = false;
327
772
 
773
+ function isTaskStale(mode = true) {
774
+ if (!mode) return false;
775
+ if (mode === 'always') return true;
776
+ if (_lastUpdatedAt === 0) return true;
777
+ if (staleTimeMs <= 0) return true;
778
+ return Date.now() - _lastUpdatedAt >= staleTimeMs;
779
+ }
780
+
328
781
  function updateSignal(name, val) {
329
782
  datastar.engine.signals.setValue(`${signalPrefix}.${name}`, val);
783
+ if (name === 'pending') {
784
+ syncAnyBusySignal();
785
+ }
330
786
  }
331
787
 
332
788
  async function run(...args) {
@@ -385,6 +841,7 @@ function createControllerRuntime(options = {}) {
385
841
  _data = result;
386
842
  _error = null;
387
843
  _stale = false;
844
+ _lastUpdatedAt = Date.now();
388
845
  updateSignal('data', result);
389
846
  updateSignal('error', null);
390
847
  updateSignal('stale', false);
@@ -398,6 +855,10 @@ function createControllerRuntime(options = {}) {
398
855
  }
399
856
  _error = err;
400
857
  updateSignal('error', err);
858
+ handleControllerError(err, {
859
+ task: taskKey,
860
+ phase: 'task',
861
+ });
401
862
  throw err;
402
863
  } finally {
403
864
  if (inFlightAbort === abortCtrl) {
@@ -446,16 +907,103 @@ function createControllerRuntime(options = {}) {
446
907
  },
447
908
  get stale() {
448
909
  datastar.engine.signals.value(`${signalPrefix}.stale`);
449
- return _stale;
910
+ return _stale || (_lastUpdatedAt > 0 && staleTimeMs > 0 && Date.now() - _lastUpdatedAt >= staleTimeMs);
911
+ },
912
+ get lastUpdatedAt() {
913
+ return _lastUpdatedAt;
450
914
  },
915
+ isStale: () => isTaskStale(true),
451
916
  run,
452
917
  cancel,
453
918
  dispose() {
919
+ if (taskIntervalTimer) {
920
+ clearInterval(taskIntervalTimer);
921
+ taskIntervalTimer = null;
922
+ }
923
+ if (focusDebounceTimer) {
924
+ clearTimeout(focusDebounceTimer);
925
+ focusDebounceTimer = null;
926
+ }
927
+ while (taskCleanupListeners.length > 0) {
928
+ const removeFn = taskCleanupListeners.pop();
929
+ try {
930
+ removeFn();
931
+ } catch (_e) {}
932
+ }
454
933
  cancel();
455
934
  datastar.engine.signals.remove(signalPrefix);
456
935
  },
457
936
  };
458
937
 
938
+ // Set up automatic background revalidation (mount / focus / reconnect / interval)
939
+ const doc = (rootEl && rootEl.ownerDocument) || (typeof document !== 'undefined' ? document : null);
940
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null);
941
+
942
+ function isDocHidden() {
943
+ return Boolean(doc && doc.visibilityState === 'hidden');
944
+ }
945
+
946
+ if (refetchOnMountMode) {
947
+ Promise.resolve().then(() => {
948
+ if (isConnected() && isTaskStale(refetchOnMountMode)) {
949
+ run().catch(() => {});
950
+ }
951
+ });
952
+ }
953
+
954
+ if (refetchOnFocusMode) {
955
+ const onTaskFocus = () => {
956
+ if (!isConnected() || isDocHidden()) return;
957
+ if (focusDebounceTimer) clearTimeout(focusDebounceTimer);
958
+ focusDebounceTimer = setTimeout(() => {
959
+ focusDebounceTimer = null;
960
+ if (isConnected() && !isDocHidden() && activeRuns === 0 && isTaskStale(refetchOnFocusMode)) {
961
+ run().catch(() => {});
962
+ }
963
+ }, 20);
964
+ };
965
+ const onVisChange = () => {
966
+ if (!isDocHidden()) onTaskFocus();
967
+ };
968
+ if (doc && typeof doc.addEventListener === 'function') {
969
+ doc.addEventListener('visibilitychange', onVisChange);
970
+ taskCleanupListeners.push(() => doc.removeEventListener('visibilitychange', onVisChange));
971
+ }
972
+ if (win && typeof win.addEventListener === 'function') {
973
+ win.addEventListener('focus', onTaskFocus);
974
+ taskCleanupListeners.push(() => win.removeEventListener('focus', onTaskFocus));
975
+ }
976
+ }
977
+
978
+ if (refetchOnReconnectMode && win && typeof win.addEventListener === 'function') {
979
+ const onTaskOnline = () => {
980
+ if (isConnected() && activeRuns === 0 && isTaskStale(refetchOnReconnectMode)) {
981
+ run().catch(() => {});
982
+ }
983
+ };
984
+ win.addEventListener('online', onTaskOnline);
985
+ taskCleanupListeners.push(() => win.removeEventListener('online', onTaskOnline));
986
+ }
987
+
988
+ if (refetchIntervalMs > 0) {
989
+ taskIntervalTimer = setInterval(() => {
990
+ if (!isConnected()) {
991
+ if (taskIntervalTimer) {
992
+ clearInterval(taskIntervalTimer);
993
+ taskIntervalTimer = null;
994
+ }
995
+ return;
996
+ }
997
+ if (!refetchIntervalInBg && isDocHidden()) return;
998
+ if (activeRuns === 0) {
999
+ run().catch(() => {});
1000
+ }
1001
+ }, refetchIntervalMs);
1002
+ if (taskIntervalTimer && typeof taskIntervalTimer.unref === 'function') {
1003
+ taskIntervalTimer.unref();
1004
+ }
1005
+ }
1006
+
459
1007
  scopedTasksList.push(taskObj);
460
1008
  return taskObj;
461
1009
  }
@@ -470,6 +1018,129 @@ function createControllerRuntime(options = {}) {
470
1018
  return stop;
471
1019
  }
472
1020
 
1021
+ function registerWatch(source, callback, options = {}) {
1022
+ if (!isConnected()) return () => {};
1023
+ if (typeof callback !== 'function') {
1024
+ throw new TypeError('watch(source, callback) requires callback to be a function.');
1025
+ }
1026
+
1027
+ const immediate = Boolean(options.immediate);
1028
+ const deep = Boolean(options.deep);
1029
+ const once = Boolean(options.once);
1030
+
1031
+ let getter;
1032
+ if (typeof source === 'function') {
1033
+ getter = () => source(ctx);
1034
+ } else if (typeof source === 'string') {
1035
+ getter = () => resolveStatePath(state, source);
1036
+ } else if (isArray(source)) {
1037
+ getter = () =>
1038
+ source.map((s) =>
1039
+ typeof s === 'function'
1040
+ ? s(ctx)
1041
+ : typeof s === 'string'
1042
+ ? resolveStatePath(state, s)
1043
+ : s
1044
+ );
1045
+ } else if (source && typeof source === 'object') {
1046
+ getter = () => source;
1047
+ } else {
1048
+ getter = () => source;
1049
+ }
1050
+
1051
+ let oldValue = undefined;
1052
+ let isFirstRun = true;
1053
+ let stopped = false;
1054
+ let userCleanup = null;
1055
+
1056
+ function runCleanup() {
1057
+ if (typeof userCleanup === 'function') {
1058
+ try {
1059
+ userCleanup();
1060
+ } catch (e) {
1061
+ handleError(e, 'watch:cleanup');
1062
+ }
1063
+ userCleanup = null;
1064
+ }
1065
+ }
1066
+
1067
+ function onCleanup(fn) {
1068
+ if (typeof fn === 'function') {
1069
+ userCleanup = fn;
1070
+ }
1071
+ }
1072
+
1073
+ function stopWatch() {
1074
+ if (stopped) return;
1075
+ stopped = true;
1076
+ runCleanup();
1077
+ stopEffect();
1078
+ }
1079
+
1080
+ const stopEffect = datastar.engine.effect(() => {
1081
+ if (stopped || !isConnected()) return;
1082
+
1083
+ let newValue;
1084
+ try {
1085
+ newValue = getter();
1086
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !isArray(source))) && newValue && typeof newValue === 'object') {
1087
+ deepTouch(newValue);
1088
+ }
1089
+ } catch (err) {
1090
+ handleError(err, 'watch:getter');
1091
+ return;
1092
+ }
1093
+
1094
+ const isArr = isArray(source);
1095
+
1096
+ if (isFirstRun) {
1097
+ isFirstRun = false;
1098
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
1099
+ if (immediate) {
1100
+ try {
1101
+ runCleanup();
1102
+ runUntracked(datastar.engine, () => {
1103
+ callback(newValue, undefined, onCleanup);
1104
+ });
1105
+ } catch (err) {
1106
+ handleError(err, 'watch:callback');
1107
+ }
1108
+ if (once) {
1109
+ stopWatch();
1110
+ }
1111
+ }
1112
+ return;
1113
+ }
1114
+
1115
+ const hasChanged = deep
1116
+ ? !deepEqual(newValue, oldValue)
1117
+ : isArr
1118
+ ? hasArrayChanged(newValue, oldValue)
1119
+ : !Object.is(newValue, oldValue);
1120
+
1121
+ if (!hasChanged) return;
1122
+
1123
+ const prev = oldValue;
1124
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
1125
+
1126
+ try {
1127
+ runCleanup();
1128
+ runUntracked(datastar.engine, () => {
1129
+ callback(newValue, prev, onCleanup);
1130
+ });
1131
+ } catch (err) {
1132
+ handleError(err, 'watch:callback');
1133
+ }
1134
+
1135
+ if (once) {
1136
+ stopWatch();
1137
+ }
1138
+ });
1139
+
1140
+ cleanupCallbacks.push(stopWatch);
1141
+ return stopWatch;
1142
+ }
1143
+
473
1144
  function registerListener(targetOrEventName, eventNameOrHandler, handlerOrOptions, maybeOptions) {
474
1145
  if (!isConnected()) return () => {};
475
1146
  let targetEl = rootEl;
@@ -605,6 +1276,7 @@ function createControllerRuntime(options = {}) {
605
1276
  api: scopedApi,
606
1277
  datastar,
607
1278
  debug,
1279
+ services,
608
1280
  ...formOptions,
609
1281
  });
610
1282
  scopedForms.set(handle.name, handle);
@@ -677,6 +1349,24 @@ function createControllerRuntime(options = {}) {
677
1349
  let state = null;
678
1350
  let ctx = null;
679
1351
 
1352
+ const controllerStorage = defaultStorage.scoped(controllerName || 'anon');
1353
+ const storageCtx = Object.freeze({
1354
+ local: controllerStorage.local,
1355
+ session: controllerStorage.session,
1356
+ memory: controllerStorage.memory,
1357
+ scoped: (sub) => controllerStorage.scoped(sub),
1358
+ global: defaultStorage,
1359
+ root: defaultStorage,
1360
+ });
1361
+ const persistConfig = normalizePersistConfig(reg.definition.persist, controllerName);
1362
+ function resolvePersistDriver(pConfig) {
1363
+ if (!pConfig) return storageCtx.local;
1364
+ if (pConfig.isGlobal) {
1365
+ return defaultStorage[pConfig.storageType] || defaultStorage.local;
1366
+ }
1367
+ return storageCtx[pConfig.storageType] || storageCtx.local;
1368
+ }
1369
+
680
1370
  // 6. Context object passed to `connect(ctx)` and `actions[name](ctx, event, actionEl)`
681
1371
  ctx = Object.freeze({
682
1372
  instanceId,
@@ -686,15 +1376,26 @@ function createControllerRuntime(options = {}) {
686
1376
  return state;
687
1377
  },
688
1378
  api: scopedApi,
1379
+ services,
689
1380
  forms: formsAccessor,
690
1381
  target: targetAccessor,
691
1382
  targets: targetsAccessor,
692
1383
  tasks: tasksProxy,
693
1384
  task: createTaskInstance,
1385
+ storage: storageCtx,
1386
+ clearPersistedState: () => {
1387
+ if (persistConfig) {
1388
+ const driver = resolvePersistDriver(persistConfig);
1389
+ driver.remove(persistConfig.storageKey);
1390
+ }
1391
+ },
694
1392
  param: (name, maybeEl) => {
695
1393
  if (!name) return null;
696
- const el = maybeEl || currentActionEl;
1394
+ const el = decorateElement(maybeEl || currentActionEl);
697
1395
  if (!el) return null;
1396
+ if (typeof el.data === 'function') {
1397
+ return el.data(name);
1398
+ }
698
1399
  const rawAttr = el.getAttribute ? el.getAttribute(`data-${name}`) : null;
699
1400
  if (rawAttr !== null) return rawAttr;
700
1401
  if (el.dataset && el.dataset[name] !== undefined) return el.dataset[name];
@@ -703,21 +1404,57 @@ function createControllerRuntime(options = {}) {
703
1404
  on: registerListener,
704
1405
  emit: emitEvent,
705
1406
  effect: registerEffect,
1407
+ watch: registerWatch,
706
1408
  interval: registerInterval,
707
1409
  timeout: registerTimeout,
708
1410
  signal: lifecycleAbort.signal,
709
1411
  isConnected,
710
1412
  isDisposed: () => !isConnected(),
711
- invalidate: (tags) => (fragments ? fragments.invalidate(tags) : Promise.resolve({ refreshed: [] })),
712
- refresh: (idOrName, input, opts) =>
713
- fragments ? fragments.refresh(idOrName, input, opts) : Promise.resolve({ refreshed: [] }),
1413
+ get isBusy() {
1414
+ return checkIsBusy();
1415
+ },
1416
+ isActionBusy,
1417
+ get lastError() {
1418
+ return lastError;
1419
+ },
1420
+ clearError() {
1421
+ lastError = null;
1422
+ },
1423
+ onError(listener) {
1424
+ if (typeof listener !== 'function') return () => {};
1425
+ dynamicErrorListeners.add(listener);
1426
+ const unregister = () => {
1427
+ dynamicErrorListeners.delete(listener);
1428
+ };
1429
+ cleanupCallbacks.push(unregister);
1430
+ return unregister;
1431
+ },
1432
+ invalidate: (tags) => (fragments ? fragments.invalidate(tags) : Promise.resolve({ ok: true, refreshed: [] })),
1433
+ refresh: async (idOrName, input, opts) => {
1434
+ if (!fragments) {
1435
+ const empty = { ok: true, refreshed: [], skipped: true };
1436
+ if (opts && typeof opts.onSuccess === 'function') await opts.onSuccess(empty);
1437
+ if (opts && typeof opts.onSettled === 'function') await opts.onSettled(empty, null);
1438
+ else if (opts && typeof opts.onComplete === 'function') await opts.onComplete(empty, null);
1439
+ return empty;
1440
+ }
1441
+ return fragments.refresh(idOrName, input, opts);
1442
+ },
714
1443
  fragment: (idOrName) => ({
715
1444
  get input() {
716
1445
  const inst = fragments && fragments.getInstance ? fragments.getInstance(idOrName) : null;
717
1446
  return inst && inst.input ? { ...inst.input } : {};
718
1447
  },
719
- refresh: (input, opts) =>
720
- fragments ? fragments.refresh(idOrName, input, opts) : Promise.resolve({ refreshed: [] }),
1448
+ refresh: (input, opts) => {
1449
+ if (!fragments) {
1450
+ const empty = { ok: true, refreshed: [], skipped: true };
1451
+ if (opts && typeof opts.onSuccess === 'function') {
1452
+ return Promise.resolve(opts.onSuccess(empty)).then(() => empty);
1453
+ }
1454
+ return Promise.resolve(empty);
1455
+ }
1456
+ return fragments.refresh(idOrName, input, opts);
1457
+ },
721
1458
  }),
722
1459
  });
723
1460
 
@@ -741,28 +1478,122 @@ function createControllerRuntime(options = {}) {
741
1478
  );
742
1479
  state = stateManager.state;
743
1480
 
1481
+ // Hydrate persisted state if defined
1482
+ if (persistConfig) {
1483
+ const driver = resolvePersistDriver(persistConfig);
1484
+ const savedData = driver.get(persistConfig.storageKey, null);
1485
+ if (savedData && typeof savedData === 'object' && !isArray(savedData)) {
1486
+ for (const [k, v] of Object.entries(savedData)) {
1487
+ if (persistConfig.keys && !persistConfig.keys.includes(k)) continue;
1488
+ if (Object.prototype.hasOwnProperty.call(initialRawState, k)) {
1489
+ state[k] = v;
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ // Register declarative watchers from definition
1496
+ if (reg.definition.watch && typeof reg.definition.watch === 'object') {
1497
+ for (const [watchKey, watchDef] of Object.entries(reg.definition.watch)) {
1498
+ if (typeof watchDef === 'function') {
1499
+ registerWatch(watchKey, (newVal, oldVal, onCleanup) => {
1500
+ watchDef.call(ctx, newVal, oldVal, onCleanup, ctx);
1501
+ });
1502
+ } else if (watchDef && typeof watchDef === 'object' && typeof watchDef.handler === 'function') {
1503
+ registerWatch(
1504
+ watchKey,
1505
+ (newVal, oldVal, onCleanup) => {
1506
+ watchDef.handler.call(ctx, newVal, oldVal, onCleanup, ctx);
1507
+ },
1508
+ {
1509
+ immediate: watchDef.immediate,
1510
+ deep: watchDef.deep,
1511
+ once: watchDef.once,
1512
+ }
1513
+ );
1514
+ }
1515
+ }
1516
+ }
1517
+
744
1518
  function invokeAction(actionName, event, actionEl) {
745
1519
  if (!isConnected()) return undefined;
746
1520
  const fn = reg.definition.actions[actionName];
747
1521
  if (typeof fn !== 'function') {
748
1522
  return undefined;
749
1523
  }
750
- currentActionEl = actionEl || null;
1524
+
1525
+ // Guard against duplicate clicks while an async action on this exact element is already in-flight
1526
+ if (
1527
+ actionEl &&
1528
+ actionEl.__wActionBusy === true &&
1529
+ (!actionEl.hasAttribute || !actionEl.hasAttribute('data-w-allow-concurrent'))
1530
+ ) {
1531
+ if (event && typeof event.preventDefault === 'function') {
1532
+ event.preventDefault();
1533
+ }
1534
+ return undefined;
1535
+ }
1536
+
1537
+ // Built-in `data-w-confirm` guard
1538
+ if (actionEl && typeof actionEl.getAttribute === 'function') {
1539
+ const confirmMsg = actionEl.getAttribute('data-w-confirm');
1540
+ if (confirmMsg !== null && confirmMsg !== '') {
1541
+ const confirmFn =
1542
+ typeof window !== 'undefined' && typeof window.confirm === 'function'
1543
+ ? window.confirm.bind(window)
1544
+ : null;
1545
+ if (confirmFn && !confirmFn(confirmMsg)) {
1546
+ if (event && typeof event.preventDefault === 'function') {
1547
+ event.preventDefault();
1548
+ }
1549
+ return undefined;
1550
+ }
1551
+ }
1552
+ }
1553
+
1554
+ const decoratedEl = decorateElement(actionEl);
1555
+ currentActionEl = decoratedEl || null;
1556
+ const prevContext = activeControllerContext;
1557
+ activeControllerContext = ctx;
751
1558
  try {
752
- const res = fn(ctx, event, actionEl);
1559
+ const res = fn(ctx, event, decoratedEl);
753
1560
  if (res && typeof res.then === 'function') {
754
- return res.catch((err) => {
755
- // Ignore canceled/superseded request errors on disconnected or superseded instances
756
- if (err && err.isCanceled) return undefined;
757
- throw err;
758
- });
1561
+ setActionBusy(actionName, 1);
1562
+ setElementBusyState(actionEl, true);
1563
+ return res.then(
1564
+ (val) => {
1565
+ setActionBusy(actionName, -1);
1566
+ setElementBusyState(actionEl, false);
1567
+ return val;
1568
+ },
1569
+ (err) => {
1570
+ setActionBusy(actionName, -1);
1571
+ setElementBusyState(actionEl, false);
1572
+ // Ignore canceled/superseded request errors on disconnected or superseded instances
1573
+ if (err && err.isCanceled) return undefined;
1574
+ handleControllerError(err, {
1575
+ action: actionName,
1576
+ event,
1577
+ actionEl: decoratedEl,
1578
+ phase: 'action',
1579
+ });
1580
+ throw err;
1581
+ }
1582
+ );
759
1583
  }
760
1584
  return res;
761
1585
  } catch (err) {
762
1586
  if (err && err.isCanceled) return undefined;
1587
+ handleControllerError(err, {
1588
+ action: actionName,
1589
+ event,
1590
+ actionEl: decoratedEl,
1591
+ phase: 'action',
1592
+ });
763
1593
  throw err;
764
1594
  } finally {
765
1595
  currentActionEl = null;
1596
+ activeControllerContext = prevContext;
766
1597
  }
767
1598
  }
768
1599
 
@@ -776,6 +1607,7 @@ function createControllerRuntime(options = {}) {
776
1607
  state,
777
1608
  invokeAction,
778
1609
  isConnected,
1610
+ isLoadingTargetActive,
779
1611
  });
780
1612
  domDisposers.push(unbind);
781
1613
 
@@ -787,7 +1619,7 @@ function createControllerRuntime(options = {}) {
787
1619
  const formsInScope = rootEl.querySelectorAll('form[data-w-form]');
788
1620
  for (let i = 0; i < formsInScope.length; i++) {
789
1621
  if (isTargetInControllerScope(rootEl, formsInScope[i])) {
790
- createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug });
1622
+ createFormHandle(formsInScope[i], { api: scopedApi, datastar, debug, services });
791
1623
  }
792
1624
  }
793
1625
  }
@@ -795,17 +1627,59 @@ function createControllerRuntime(options = {}) {
795
1627
 
796
1628
  rebindDom();
797
1629
 
1630
+ // Auto-sync persisted state changes back to storage
1631
+ if (persistConfig) {
1632
+ const driver = resolvePersistDriver(persistConfig);
1633
+ let persistTimer = null;
1634
+ let lastSavedJson = '';
1635
+
1636
+ const stopPersistEffect = registerEffect(() => {
1637
+ if (!isConnected()) return;
1638
+ const keysToTrack = persistConfig.keys || Object.keys(initialRawState);
1639
+ const payload = {};
1640
+ for (const k of keysToTrack) {
1641
+ payload[k] = state[k];
1642
+ }
1643
+
1644
+ const serialized = JSON.stringify(payload);
1645
+ if (serialized === lastSavedJson) return;
1646
+
1647
+ const writeToStorage = () => {
1648
+ if (!isConnected()) return;
1649
+ driver.set(persistConfig.storageKey, payload);
1650
+ lastSavedJson = serialized;
1651
+ };
1652
+
1653
+ if (persistConfig.debounce > 0) {
1654
+ if (persistTimer) clearTimeout(persistTimer);
1655
+ persistTimer = setTimeout(writeToStorage, persistConfig.debounce);
1656
+ } else {
1657
+ writeToStorage();
1658
+ }
1659
+ });
1660
+
1661
+ cleanupCallbacks.push(() => {
1662
+ if (persistTimer) clearTimeout(persistTimer);
1663
+ if (typeof stopPersistEffect === 'function') stopPersistEffect();
1664
+ });
1665
+ }
1666
+
798
1667
  // 8. Run `connect(ctx)` lifecycle hook
799
1668
  if (reg.definition.connect) {
1669
+ const prevContext = activeControllerContext;
1670
+ activeControllerContext = ctx;
800
1671
  try {
801
1672
  const userCleanup = reg.definition.connect(ctx);
802
1673
  if (typeof userCleanup === 'function') {
803
1674
  cleanupCallbacks.push(userCleanup);
804
1675
  }
805
1676
  } catch (err) {
1677
+ handleControllerError(err, { phase: 'connect' });
806
1678
  if (debug) {
807
1679
  debug.trackCleanupError(instanceId, controllerName, 'connect', err);
808
1680
  }
1681
+ } finally {
1682
+ activeControllerContext = prevContext;
809
1683
  }
810
1684
  }
811
1685
 
@@ -814,7 +1688,9 @@ function createControllerRuntime(options = {}) {
814
1688
  name: controllerName,
815
1689
  el: rootEl,
816
1690
  state,
1691
+ tasks: tasksProxy,
817
1692
  ctx,
1693
+ invokeAction,
818
1694
  actions: new Proxy({}, {
819
1695
  get(_target, prop) {
820
1696
  if (typeof prop === 'string') {
@@ -837,6 +1713,7 @@ function createControllerRuntime(options = {}) {
837
1713
  try {
838
1714
  reg.definition.disconnect(ctx);
839
1715
  } catch (err) {
1716
+ handleControllerError(err, { phase: 'disconnect' });
840
1717
  if (debug) {
841
1718
  debug.trackCleanupError(instanceId, controllerName, 'disconnect', err);
842
1719
  }
@@ -887,6 +1764,8 @@ function createControllerRuntime(options = {}) {
887
1764
 
888
1765
  // Dispose isolated Datastar signals for this instance
889
1766
  stateManager.dispose();
1767
+ dynamicErrorListeners.clear();
1768
+ lastError = null;
890
1769
 
891
1770
  const mapOnEl = elementInstances.get(rootEl);
892
1771
  if (mapOnEl) {
@@ -1019,6 +1898,7 @@ function createControllerRuntime(options = {}) {
1019
1898
  api: defaultApi,
1020
1899
  datastar,
1021
1900
  debug,
1901
+ services,
1022
1902
  });
1023
1903
  standaloneForms.set(formEl, handle);
1024
1904
  }
@@ -1043,6 +1923,7 @@ function createControllerRuntime(options = {}) {
1043
1923
 
1044
1924
  return {
1045
1925
  register,
1926
+ services,
1046
1927
  hasController(name) {
1047
1928
  return definitions.has(name);
1048
1929
  },
@@ -1077,5 +1958,7 @@ module.exports = {
1077
1958
  createControllerRuntime,
1078
1959
  queryScopedTargets,
1079
1960
  isTargetInControllerScope,
1961
+ watch,
1962
+ getActiveControllerContext,
1080
1963
  };
1081
1964