webspresso 0.0.109 → 0.0.110

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,203 @@
17
17
 
18
18
  const { createFormHandle } = require('./forms');
19
19
  const { createApiClient } = require('./api');
20
+ const { defaultStorage, createClientStorage } = require('./storage');
21
+ const { decorateElement } = require('./utils');
20
22
 
21
23
  let instanceSeq = 0;
24
+ let activeControllerContext = null;
25
+
26
+ function getActiveControllerContext() {
27
+ return activeControllerContext;
28
+ }
29
+
30
+ function resolveStatePath(root, pathStr) {
31
+ if (!root || !pathStr) return undefined;
32
+ const parts = String(pathStr).split('.');
33
+ let cur = root;
34
+ for (let i = 0; i < parts.length; i++) {
35
+ if (cur === null || typeof cur !== 'object') return undefined;
36
+ cur = cur[parts[i]];
37
+ }
38
+ return cur;
39
+ }
40
+
41
+ function deepTouch(val, visited = new Set()) {
42
+ if (val === null || typeof val !== 'object' || visited.has(val)) {
43
+ return;
44
+ }
45
+ visited.add(val);
46
+ if (Array.isArray(val)) {
47
+ for (let i = 0; i < val.length; i++) {
48
+ deepTouch(val[i], visited);
49
+ }
50
+ } else {
51
+ const keys = Object.keys(val);
52
+ for (let i = 0; i < keys.length; i++) {
53
+ deepTouch(val[keys[i]], visited);
54
+ }
55
+ }
56
+ }
57
+
58
+ function deepCloneOrSnapshot(val) {
59
+ if (val === null || typeof val !== 'object') return val;
60
+ try {
61
+ return JSON.parse(JSON.stringify(val));
62
+ } catch {
63
+ return Array.isArray(val) ? [...val] : { ...val };
64
+ }
65
+ }
66
+
67
+ function deepEqual(a, b) {
68
+ if (Object.is(a, b)) return true;
69
+ if (a === null || typeof a !== 'object' || b === null || typeof b !== 'object') return false;
70
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
71
+ const keysA = Object.keys(a);
72
+ const keysB = Object.keys(b);
73
+ if (keysA.length !== keysB.length) return false;
74
+ for (let i = 0; i < keysA.length; i++) {
75
+ const k = keysA[i];
76
+ if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEqual(a[k], b[k])) {
77
+ return false;
78
+ }
79
+ }
80
+ return true;
81
+ }
82
+
83
+ function hasArrayChanged(newArr, oldArr) {
84
+ if (!Array.isArray(newArr) || !Array.isArray(oldArr)) return !Object.is(newArr, oldArr);
85
+ if (newArr.length !== oldArr.length) return true;
86
+ for (let i = 0; i < newArr.length; i++) {
87
+ if (!Object.is(newArr[i], oldArr[i])) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function runUntracked(engine, fn) {
93
+ if (engine && typeof engine.untracked === 'function') {
94
+ return engine.untracked(fn);
95
+ }
96
+ return fn();
97
+ }
98
+
99
+ function createStandaloneWatch(engine, source, callback, options = {}) {
100
+ if (typeof callback !== 'function') {
101
+ throw new TypeError('watch(source, callback) requires callback to be a function.');
102
+ }
103
+
104
+ const immediate = Boolean(options.immediate);
105
+ const deep = Boolean(options.deep);
106
+ const once = Boolean(options.once);
107
+
108
+ let getter;
109
+ if (typeof source === 'function') {
110
+ getter = source;
111
+ } else if (Array.isArray(source)) {
112
+ getter = () => source.map((s) => (typeof s === 'function' ? s() : s));
113
+ } else {
114
+ getter = () => source;
115
+ }
116
+
117
+ let oldValue = undefined;
118
+ let isFirstRun = true;
119
+ let stopped = false;
120
+ let userCleanup = null;
121
+
122
+ function runCleanup() {
123
+ if (typeof userCleanup === 'function') {
124
+ try {
125
+ userCleanup();
126
+ } catch (_e) {}
127
+ userCleanup = null;
128
+ }
129
+ }
130
+
131
+ function onCleanup(fn) {
132
+ if (typeof fn === 'function') {
133
+ userCleanup = fn;
134
+ }
135
+ }
136
+
137
+ function stopWatch() {
138
+ if (stopped) return;
139
+ stopped = true;
140
+ runCleanup();
141
+ stopEffect();
142
+ }
143
+
144
+ const stopEffect = engine.effect(() => {
145
+ if (stopped) return;
146
+
147
+ let newValue;
148
+ try {
149
+ newValue = getter();
150
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !Array.isArray(source))) && newValue && typeof newValue === 'object') {
151
+ deepTouch(newValue);
152
+ }
153
+ } catch (_err) {
154
+ return;
155
+ }
156
+
157
+ const isArr = Array.isArray(source);
158
+
159
+ if (isFirstRun) {
160
+ isFirstRun = false;
161
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
162
+ if (immediate) {
163
+ runCleanup();
164
+ runUntracked(engine, () => {
165
+ callback(newValue, undefined, onCleanup);
166
+ });
167
+ if (once) stopWatch();
168
+ }
169
+ return;
170
+ }
171
+
172
+ const hasChanged = deep
173
+ ? !deepEqual(newValue, oldValue)
174
+ : isArr
175
+ ? hasArrayChanged(newValue, oldValue)
176
+ : !Object.is(newValue, oldValue);
177
+
178
+ if (!hasChanged) return;
179
+
180
+ const prev = oldValue;
181
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
182
+
183
+ runCleanup();
184
+ runUntracked(engine, () => {
185
+ callback(newValue, prev, onCleanup);
186
+ });
187
+
188
+ if (once) stopWatch();
189
+ });
190
+
191
+ return stopWatch;
192
+ }
193
+
194
+ /**
195
+ * Vue-style reactive watcher.
196
+ *
197
+ * @template T
198
+ * @param {(() => T) | string | Array<any>} source - Reactive getter function, state dot-path, or array of sources
199
+ * @param {(newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void} callback - Handler invoked when value changes
200
+ * @param {object} [options]
201
+ * @param {boolean} [options.immediate=false] - Trigger callback immediately on registration
202
+ * @param {boolean} [options.deep=false] - Deep watch nested object/array mutations
203
+ * @param {boolean} [options.once=false] - Stop watching automatically after first trigger
204
+ * @returns {() => void} Unwatch function
205
+ */
206
+ function watch(source, callback, options = {}) {
207
+ if (activeControllerContext && typeof activeControllerContext.watch === 'function') {
208
+ return activeControllerContext.watch(source, callback, options);
209
+ }
210
+ const { createDatastarAdapter } = require('./adapter/datastar');
211
+ const engine = createDatastarAdapter().engine;
212
+ if (!engine || typeof engine.effect !== 'function') {
213
+ return () => {};
214
+ }
215
+ return createStandaloneWatch(engine, source, callback, options);
216
+ }
22
217
 
23
218
  /**
24
219
  * Derive a deterministic controller name from a `.client.{js,ts}` file path.
@@ -33,34 +228,67 @@ let instanceSeq = 0;
33
228
  * @param {string} filePath
34
229
  * @returns {string}
35
230
  */
231
+ function stripBrackets(str) {
232
+ return str.replace(/[\[\]]/g, '');
233
+ }
234
+
235
+ function extractAfterMarker(path, marker) {
236
+ const candidates = ['/src/' + marker, 'src/' + marker, '/' + marker, marker];
237
+ for (const cand of candidates) {
238
+ const idx = path.lastIndexOf(cand);
239
+ if (idx !== -1) {
240
+ if (cand.startsWith('/') || idx === 0) {
241
+ return path.slice(idx + cand.length);
242
+ }
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function stripClientExtension(path) {
249
+ const exts = ['.client.ts', '.client.tsx', '.client.js', '.client.jsx', '.client.mjs', '.client.cjs'];
250
+ const lower = path.toLowerCase();
251
+ for (const ext of exts) {
252
+ if (lower.endsWith(ext)) {
253
+ return path.slice(0, -ext.length);
254
+ }
255
+ }
256
+ return path;
257
+ }
258
+
36
259
  function deriveControllerNameFromPath(filePath) {
37
260
  if (!filePath || typeof filePath !== 'string') return '';
38
261
  const normalized = filePath.replace(/\\/g, '/');
262
+ const withoutExt = stripClientExtension(normalized);
39
263
 
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');
264
+ // Check if under views/components/
265
+ const compRel = extractAfterMarker(withoutExt, 'views/components/');
266
+ if (compRel) {
267
+ return stripBrackets(compRel.split('/').join('--'));
47
268
  }
48
269
 
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');
270
+ // Check if under views/
271
+ const viewsRel = extractAfterMarker(withoutExt, 'views/');
272
+ if (viewsRel) {
273
+ const base = viewsRel.split('/').pop() || viewsRel;
274
+ return stripBrackets(base);
54
275
  }
55
276
 
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');
277
+ // Check if under pages/
278
+ const pagesRel = extractAfterMarker(withoutExt, 'pages/');
279
+ if (pagesRel) {
280
+ let rel = pagesRel;
281
+ if (rel.endsWith('/index')) {
282
+ rel = rel.slice(0, -6);
283
+ }
284
+ if (!rel || rel === 'index') {
285
+ rel = 'index';
286
+ }
287
+ return stripBrackets(rel.split('/').join('--'));
60
288
  }
61
289
 
62
290
  const base = withoutExt.split('/').pop() || withoutExt;
63
- return base.replace(/\[([^\]]+)\]/g, '$1');
291
+ return stripBrackets(base);
64
292
  }
65
293
 
66
294
  /**
@@ -89,6 +317,23 @@ function defineController(definition = {}) {
89
317
  if (definition.tasks !== undefined && (typeof definition.tasks !== 'object' || definition.tasks === null)) {
90
318
  throw new TypeError('defineController({ tasks }) must be an object of task factory functions.');
91
319
  }
320
+ if (definition.watch !== undefined && (typeof definition.watch !== 'object' || definition.watch === null)) {
321
+ throw new TypeError('defineController({ watch }) must be an object of watcher functions or descriptors.');
322
+ }
323
+ if (definition.onError !== undefined && typeof definition.onError !== 'function') {
324
+ throw new TypeError('defineController({ onError }) must be an error handler function `(err, ctx, meta) => void`.');
325
+ }
326
+ if (definition.persist !== undefined && definition.persist !== null) {
327
+ const p = definition.persist;
328
+ const isArr = Array.isArray(p);
329
+ const isStr = typeof p === 'string';
330
+ const isObj = typeof p === 'object' && p !== null && !isArr;
331
+ if (!isArr && !isStr && !isObj) {
332
+ throw new TypeError(
333
+ 'defineController({ persist }) must be an array of keys, storage type string ("local" | "session"), or a config object { keys?, storage?, key?, debounce? }.'
334
+ );
335
+ }
336
+ }
92
337
 
93
338
  // Pre-validate collision between state and computed properties
94
339
  if (definition.computed && typeof definition.computed === 'object') {
@@ -114,10 +359,53 @@ function defineController(definition = {}) {
114
359
  tasks: definition.tasks && typeof definition.tasks === 'object' ? definition.tasks : {},
115
360
  connect: typeof definition.connect === 'function' ? definition.connect : null,
116
361
  disconnect: typeof definition.disconnect === 'function' ? definition.disconnect : null,
362
+ onError: typeof definition.onError === 'function' ? definition.onError : null,
363
+ persist: definition.persist || null,
364
+ watch: definition.watch && typeof definition.watch === 'object' ? definition.watch : null,
117
365
  actions: definition.actions && typeof definition.actions === 'object' ? definition.actions : {},
118
366
  });
119
367
  }
120
368
 
369
+ /**
370
+ * Normalize controller persist configuration.
371
+ *
372
+ * @param {any} persistOpt
373
+ * @param {string|null} controllerName
374
+ * @returns {{ keys: string[]|null, storageType: 'local'|'session'|'memory', storageKey: string, debounce: number }|null}
375
+ */
376
+ function normalizePersistConfig(persistOpt, controllerName) {
377
+ if (!persistOpt) return null;
378
+ let keys = null;
379
+ let storageType = 'local';
380
+ let storageKey = 'state';
381
+ let isGlobal = false;
382
+ let debounce = 50;
383
+
384
+ if (Array.isArray(persistOpt)) {
385
+ keys = persistOpt.map(String);
386
+ } else if (typeof persistOpt === 'string') {
387
+ const s = persistOpt.trim().toLowerCase();
388
+ storageType = s === 'session' ? 'session' : 'local';
389
+ } else if (typeof persistOpt === 'object' && persistOpt !== null) {
390
+ if (Array.isArray(persistOpt.keys)) {
391
+ keys = persistOpt.keys.map(String);
392
+ }
393
+ if (persistOpt.storage === 'session' || persistOpt.storage === 'memory') {
394
+ storageType = persistOpt.storage;
395
+ }
396
+ if (typeof persistOpt.key === 'string' && persistOpt.key.trim()) {
397
+ storageKey = persistOpt.key.trim();
398
+ }
399
+ if (Boolean(persistOpt.global)) {
400
+ isGlobal = true;
401
+ }
402
+ if (typeof persistOpt.debounce === 'number') {
403
+ debounce = Math.max(0, persistOpt.debounce);
404
+ }
405
+ }
406
+ return { keys, storageType, storageKey, isGlobal, debounce };
407
+ }
408
+
121
409
  /**
122
410
  * Check if `candidateEl` belongs to `rootEl`'s controller scope for `controllerName`,
123
411
  * and is NOT inside a nested `[data-w-controller]` element.
@@ -159,7 +447,7 @@ function queryScopedTargets(rootEl, controllerName, targetName) {
159
447
  const rawAttr = (el.getAttribute('data-w-target') || '').trim();
160
448
  const tokens = rawAttr.split(/\s+/);
161
449
  if (tokens.includes(cleanTarget) || tokens.includes(prefixedTarget)) {
162
- matched.push(el);
450
+ matched.push(decorateElement(el));
163
451
  }
164
452
  }
165
453
 
@@ -201,11 +489,18 @@ function createControllerRuntime(options = {}) {
201
489
  * @param {string} [meta.sourcePath]
202
490
  * @param {string[]} [meta.aliases]
203
491
  */
204
- function register(name, rawDef, meta = {}) {
492
+ function register(nameOrDef, rawDef, meta = {}) {
493
+ let name = nameOrDef;
494
+ let actualDef = rawDef;
495
+ if (typeof nameOrDef === 'object' && nameOrDef !== null && !rawDef) {
496
+ actualDef = nameOrDef;
497
+ name = actualDef.name || (actualDef.default && actualDef.default.name) || null;
498
+ }
499
+
205
500
  const def =
206
- rawDef && rawDef.__isWebspressoController
207
- ? rawDef
208
- : defineController(rawDef && rawDef.default ? rawDef.default : rawDef);
501
+ actualDef && actualDef.__isWebspressoController
502
+ ? actualDef
503
+ : defineController(actualDef && actualDef.default ? actualDef.default : actualDef);
209
504
 
210
505
  const canonicalName = String(name || def.name || '').trim();
211
506
  if (!canonicalName) {
@@ -293,6 +588,48 @@ function createControllerRuntime(options = {}) {
293
588
  const scopedTasks = {};
294
589
  const scopedTasksList = [];
295
590
  let currentActionEl = null;
591
+ let lastError = null;
592
+ const dynamicErrorListeners = new Set();
593
+
594
+ function handleControllerError(err, meta = {}) {
595
+ if (!err || err.isCanceled) {
596
+ return;
597
+ }
598
+
599
+ lastError = err;
600
+
601
+ // 1. Controller definition onError hook
602
+ if (typeof reg.definition.onError === 'function') {
603
+ try {
604
+ reg.definition.onError(err, ctx, meta);
605
+ } catch (hookErr) {
606
+ if (debug) {
607
+ debug.trackCleanupError(instanceId, controllerName, 'onError', hookErr);
608
+ }
609
+ }
610
+ }
611
+
612
+ // 2. Dynamic ctx.onError listeners
613
+ for (const listener of Array.from(dynamicErrorListeners)) {
614
+ try {
615
+ listener(err, meta);
616
+ } catch (_listenerErr) {
617
+ // Ignore
618
+ }
619
+ }
620
+
621
+ // 3. Dispatch bubbling CustomEvent 'w-controller:error' on rootEl
622
+ emitEvent(
623
+ 'w-controller:error',
624
+ {
625
+ error: err,
626
+ instanceId,
627
+ controllerName,
628
+ ...meta,
629
+ },
630
+ { bubbles: true, cancelable: true }
631
+ );
632
+ }
296
633
 
297
634
  let taskSeq = 0;
298
635
  function createTaskInstance(runFnOrConfig, options = {}) {
@@ -398,6 +735,10 @@ function createControllerRuntime(options = {}) {
398
735
  }
399
736
  _error = err;
400
737
  updateSignal('error', err);
738
+ handleControllerError(err, {
739
+ task: taskKey,
740
+ phase: 'task',
741
+ });
401
742
  throw err;
402
743
  } finally {
403
744
  if (inFlightAbort === abortCtrl) {
@@ -470,6 +811,129 @@ function createControllerRuntime(options = {}) {
470
811
  return stop;
471
812
  }
472
813
 
814
+ function registerWatch(source, callback, options = {}) {
815
+ if (!isConnected()) return () => {};
816
+ if (typeof callback !== 'function') {
817
+ throw new TypeError('watch(source, callback) requires callback to be a function.');
818
+ }
819
+
820
+ const immediate = Boolean(options.immediate);
821
+ const deep = Boolean(options.deep);
822
+ const once = Boolean(options.once);
823
+
824
+ let getter;
825
+ if (typeof source === 'function') {
826
+ getter = () => source(ctx);
827
+ } else if (typeof source === 'string') {
828
+ getter = () => resolveStatePath(state, source);
829
+ } else if (Array.isArray(source)) {
830
+ getter = () =>
831
+ source.map((s) =>
832
+ typeof s === 'function'
833
+ ? s(ctx)
834
+ : typeof s === 'string'
835
+ ? resolveStatePath(state, s)
836
+ : s
837
+ );
838
+ } else if (source && typeof source === 'object') {
839
+ getter = () => source;
840
+ } else {
841
+ getter = () => source;
842
+ }
843
+
844
+ let oldValue = undefined;
845
+ let isFirstRun = true;
846
+ let stopped = false;
847
+ let userCleanup = null;
848
+
849
+ function runCleanup() {
850
+ if (typeof userCleanup === 'function') {
851
+ try {
852
+ userCleanup();
853
+ } catch (e) {
854
+ handleError(e, 'watch:cleanup');
855
+ }
856
+ userCleanup = null;
857
+ }
858
+ }
859
+
860
+ function onCleanup(fn) {
861
+ if (typeof fn === 'function') {
862
+ userCleanup = fn;
863
+ }
864
+ }
865
+
866
+ function stopWatch() {
867
+ if (stopped) return;
868
+ stopped = true;
869
+ runCleanup();
870
+ stopEffect();
871
+ }
872
+
873
+ const stopEffect = datastar.engine.effect(() => {
874
+ if (stopped || !isConnected()) return;
875
+
876
+ let newValue;
877
+ try {
878
+ newValue = getter();
879
+ if ((deep || (source && typeof source === 'object' && typeof source !== 'function' && !Array.isArray(source))) && newValue && typeof newValue === 'object') {
880
+ deepTouch(newValue);
881
+ }
882
+ } catch (err) {
883
+ handleError(err, 'watch:getter');
884
+ return;
885
+ }
886
+
887
+ const isArr = Array.isArray(source);
888
+
889
+ if (isFirstRun) {
890
+ isFirstRun = false;
891
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
892
+ if (immediate) {
893
+ try {
894
+ runCleanup();
895
+ runUntracked(datastar.engine, () => {
896
+ callback(newValue, undefined, onCleanup);
897
+ });
898
+ } catch (err) {
899
+ handleError(err, 'watch:callback');
900
+ }
901
+ if (once) {
902
+ stopWatch();
903
+ }
904
+ }
905
+ return;
906
+ }
907
+
908
+ const hasChanged = deep
909
+ ? !deepEqual(newValue, oldValue)
910
+ : isArr
911
+ ? hasArrayChanged(newValue, oldValue)
912
+ : !Object.is(newValue, oldValue);
913
+
914
+ if (!hasChanged) return;
915
+
916
+ const prev = oldValue;
917
+ oldValue = deep ? deepCloneOrSnapshot(newValue) : isArr ? [...newValue] : newValue;
918
+
919
+ try {
920
+ runCleanup();
921
+ runUntracked(datastar.engine, () => {
922
+ callback(newValue, prev, onCleanup);
923
+ });
924
+ } catch (err) {
925
+ handleError(err, 'watch:callback');
926
+ }
927
+
928
+ if (once) {
929
+ stopWatch();
930
+ }
931
+ });
932
+
933
+ cleanupCallbacks.push(stopWatch);
934
+ return stopWatch;
935
+ }
936
+
473
937
  function registerListener(targetOrEventName, eventNameOrHandler, handlerOrOptions, maybeOptions) {
474
938
  if (!isConnected()) return () => {};
475
939
  let targetEl = rootEl;
@@ -677,6 +1141,24 @@ function createControllerRuntime(options = {}) {
677
1141
  let state = null;
678
1142
  let ctx = null;
679
1143
 
1144
+ const controllerStorage = defaultStorage.scoped(controllerName || 'anon');
1145
+ const storageCtx = Object.freeze({
1146
+ local: controllerStorage.local,
1147
+ session: controllerStorage.session,
1148
+ memory: controllerStorage.memory,
1149
+ scoped: (sub) => controllerStorage.scoped(sub),
1150
+ global: defaultStorage,
1151
+ root: defaultStorage,
1152
+ });
1153
+ const persistConfig = normalizePersistConfig(reg.definition.persist, controllerName);
1154
+ function resolvePersistDriver(pConfig) {
1155
+ if (!pConfig) return storageCtx.local;
1156
+ if (pConfig.isGlobal) {
1157
+ return defaultStorage[pConfig.storageType] || defaultStorage.local;
1158
+ }
1159
+ return storageCtx[pConfig.storageType] || storageCtx.local;
1160
+ }
1161
+
680
1162
  // 6. Context object passed to `connect(ctx)` and `actions[name](ctx, event, actionEl)`
681
1163
  ctx = Object.freeze({
682
1164
  instanceId,
@@ -691,10 +1173,20 @@ function createControllerRuntime(options = {}) {
691
1173
  targets: targetsAccessor,
692
1174
  tasks: tasksProxy,
693
1175
  task: createTaskInstance,
1176
+ storage: storageCtx,
1177
+ clearPersistedState: () => {
1178
+ if (persistConfig) {
1179
+ const driver = resolvePersistDriver(persistConfig);
1180
+ driver.remove(persistConfig.storageKey);
1181
+ }
1182
+ },
694
1183
  param: (name, maybeEl) => {
695
1184
  if (!name) return null;
696
- const el = maybeEl || currentActionEl;
1185
+ const el = decorateElement(maybeEl || currentActionEl);
697
1186
  if (!el) return null;
1187
+ if (typeof el.data === 'function') {
1188
+ return el.data(name);
1189
+ }
698
1190
  const rawAttr = el.getAttribute ? el.getAttribute(`data-${name}`) : null;
699
1191
  if (rawAttr !== null) return rawAttr;
700
1192
  if (el.dataset && el.dataset[name] !== undefined) return el.dataset[name];
@@ -703,21 +1195,53 @@ function createControllerRuntime(options = {}) {
703
1195
  on: registerListener,
704
1196
  emit: emitEvent,
705
1197
  effect: registerEffect,
1198
+ watch: registerWatch,
706
1199
  interval: registerInterval,
707
1200
  timeout: registerTimeout,
708
1201
  signal: lifecycleAbort.signal,
709
1202
  isConnected,
710
1203
  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: [] }),
1204
+ get lastError() {
1205
+ return lastError;
1206
+ },
1207
+ clearError() {
1208
+ lastError = null;
1209
+ },
1210
+ onError(listener) {
1211
+ if (typeof listener !== 'function') return () => {};
1212
+ dynamicErrorListeners.add(listener);
1213
+ const unregister = () => {
1214
+ dynamicErrorListeners.delete(listener);
1215
+ };
1216
+ cleanupCallbacks.push(unregister);
1217
+ return unregister;
1218
+ },
1219
+ invalidate: (tags) => (fragments ? fragments.invalidate(tags) : Promise.resolve({ ok: true, refreshed: [] })),
1220
+ refresh: async (idOrName, input, opts) => {
1221
+ if (!fragments) {
1222
+ const empty = { ok: true, refreshed: [], skipped: true };
1223
+ if (opts && typeof opts.onSuccess === 'function') await opts.onSuccess(empty);
1224
+ if (opts && typeof opts.onSettled === 'function') await opts.onSettled(empty, null);
1225
+ else if (opts && typeof opts.onComplete === 'function') await opts.onComplete(empty, null);
1226
+ return empty;
1227
+ }
1228
+ return fragments.refresh(idOrName, input, opts);
1229
+ },
714
1230
  fragment: (idOrName) => ({
715
1231
  get input() {
716
1232
  const inst = fragments && fragments.getInstance ? fragments.getInstance(idOrName) : null;
717
1233
  return inst && inst.input ? { ...inst.input } : {};
718
1234
  },
719
- refresh: (input, opts) =>
720
- fragments ? fragments.refresh(idOrName, input, opts) : Promise.resolve({ refreshed: [] }),
1235
+ refresh: (input, opts) => {
1236
+ if (!fragments) {
1237
+ const empty = { ok: true, refreshed: [], skipped: true };
1238
+ if (opts && typeof opts.onSuccess === 'function') {
1239
+ return Promise.resolve(opts.onSuccess(empty)).then(() => empty);
1240
+ }
1241
+ return Promise.resolve(empty);
1242
+ }
1243
+ return fragments.refresh(idOrName, input, opts);
1244
+ },
721
1245
  }),
722
1246
  });
723
1247
 
@@ -741,28 +1265,81 @@ function createControllerRuntime(options = {}) {
741
1265
  );
742
1266
  state = stateManager.state;
743
1267
 
1268
+ // Hydrate persisted state if defined
1269
+ if (persistConfig) {
1270
+ const driver = resolvePersistDriver(persistConfig);
1271
+ const savedData = driver.get(persistConfig.storageKey, null);
1272
+ if (savedData && typeof savedData === 'object' && !Array.isArray(savedData)) {
1273
+ for (const [k, v] of Object.entries(savedData)) {
1274
+ if (persistConfig.keys && !persistConfig.keys.includes(k)) continue;
1275
+ if (Object.prototype.hasOwnProperty.call(initialRawState, k)) {
1276
+ state[k] = v;
1277
+ }
1278
+ }
1279
+ }
1280
+ }
1281
+
1282
+ // Register declarative watchers from definition
1283
+ if (reg.definition.watch && typeof reg.definition.watch === 'object') {
1284
+ for (const [watchKey, watchDef] of Object.entries(reg.definition.watch)) {
1285
+ if (typeof watchDef === 'function') {
1286
+ registerWatch(watchKey, (newVal, oldVal, onCleanup) => {
1287
+ watchDef.call(ctx, newVal, oldVal, onCleanup, ctx);
1288
+ });
1289
+ } else if (watchDef && typeof watchDef === 'object' && typeof watchDef.handler === 'function') {
1290
+ registerWatch(
1291
+ watchKey,
1292
+ (newVal, oldVal, onCleanup) => {
1293
+ watchDef.handler.call(ctx, newVal, oldVal, onCleanup, ctx);
1294
+ },
1295
+ {
1296
+ immediate: watchDef.immediate,
1297
+ deep: watchDef.deep,
1298
+ once: watchDef.once,
1299
+ }
1300
+ );
1301
+ }
1302
+ }
1303
+ }
1304
+
744
1305
  function invokeAction(actionName, event, actionEl) {
745
1306
  if (!isConnected()) return undefined;
746
1307
  const fn = reg.definition.actions[actionName];
747
1308
  if (typeof fn !== 'function') {
748
1309
  return undefined;
749
1310
  }
750
- currentActionEl = actionEl || null;
1311
+ const decoratedEl = decorateElement(actionEl);
1312
+ currentActionEl = decoratedEl || null;
1313
+ const prevContext = activeControllerContext;
1314
+ activeControllerContext = ctx;
751
1315
  try {
752
- const res = fn(ctx, event, actionEl);
1316
+ const res = fn(ctx, event, decoratedEl);
753
1317
  if (res && typeof res.then === 'function') {
754
1318
  return res.catch((err) => {
755
1319
  // Ignore canceled/superseded request errors on disconnected or superseded instances
756
1320
  if (err && err.isCanceled) return undefined;
1321
+ handleControllerError(err, {
1322
+ action: actionName,
1323
+ event,
1324
+ actionEl: decoratedEl,
1325
+ phase: 'action',
1326
+ });
757
1327
  throw err;
758
1328
  });
759
1329
  }
760
1330
  return res;
761
1331
  } catch (err) {
762
1332
  if (err && err.isCanceled) return undefined;
1333
+ handleControllerError(err, {
1334
+ action: actionName,
1335
+ event,
1336
+ actionEl: decoratedEl,
1337
+ phase: 'action',
1338
+ });
763
1339
  throw err;
764
1340
  } finally {
765
1341
  currentActionEl = null;
1342
+ activeControllerContext = prevContext;
766
1343
  }
767
1344
  }
768
1345
 
@@ -795,17 +1372,59 @@ function createControllerRuntime(options = {}) {
795
1372
 
796
1373
  rebindDom();
797
1374
 
1375
+ // Auto-sync persisted state changes back to storage
1376
+ if (persistConfig) {
1377
+ const driver = resolvePersistDriver(persistConfig);
1378
+ let persistTimer = null;
1379
+ let lastSavedJson = '';
1380
+
1381
+ const stopPersistEffect = registerEffect(() => {
1382
+ if (!isConnected()) return;
1383
+ const keysToTrack = persistConfig.keys || Object.keys(initialRawState);
1384
+ const payload = {};
1385
+ for (const k of keysToTrack) {
1386
+ payload[k] = state[k];
1387
+ }
1388
+
1389
+ const serialized = JSON.stringify(payload);
1390
+ if (serialized === lastSavedJson) return;
1391
+
1392
+ const writeToStorage = () => {
1393
+ if (!isConnected()) return;
1394
+ driver.set(persistConfig.storageKey, payload);
1395
+ lastSavedJson = serialized;
1396
+ };
1397
+
1398
+ if (persistConfig.debounce > 0) {
1399
+ if (persistTimer) clearTimeout(persistTimer);
1400
+ persistTimer = setTimeout(writeToStorage, persistConfig.debounce);
1401
+ } else {
1402
+ writeToStorage();
1403
+ }
1404
+ });
1405
+
1406
+ cleanupCallbacks.push(() => {
1407
+ if (persistTimer) clearTimeout(persistTimer);
1408
+ if (typeof stopPersistEffect === 'function') stopPersistEffect();
1409
+ });
1410
+ }
1411
+
798
1412
  // 8. Run `connect(ctx)` lifecycle hook
799
1413
  if (reg.definition.connect) {
1414
+ const prevContext = activeControllerContext;
1415
+ activeControllerContext = ctx;
800
1416
  try {
801
1417
  const userCleanup = reg.definition.connect(ctx);
802
1418
  if (typeof userCleanup === 'function') {
803
1419
  cleanupCallbacks.push(userCleanup);
804
1420
  }
805
1421
  } catch (err) {
1422
+ handleControllerError(err, { phase: 'connect' });
806
1423
  if (debug) {
807
1424
  debug.trackCleanupError(instanceId, controllerName, 'connect', err);
808
1425
  }
1426
+ } finally {
1427
+ activeControllerContext = prevContext;
809
1428
  }
810
1429
  }
811
1430
 
@@ -815,6 +1434,7 @@ function createControllerRuntime(options = {}) {
815
1434
  el: rootEl,
816
1435
  state,
817
1436
  ctx,
1437
+ invokeAction,
818
1438
  actions: new Proxy({}, {
819
1439
  get(_target, prop) {
820
1440
  if (typeof prop === 'string') {
@@ -837,6 +1457,7 @@ function createControllerRuntime(options = {}) {
837
1457
  try {
838
1458
  reg.definition.disconnect(ctx);
839
1459
  } catch (err) {
1460
+ handleControllerError(err, { phase: 'disconnect' });
840
1461
  if (debug) {
841
1462
  debug.trackCleanupError(instanceId, controllerName, 'disconnect', err);
842
1463
  }
@@ -887,6 +1508,8 @@ function createControllerRuntime(options = {}) {
887
1508
 
888
1509
  // Dispose isolated Datastar signals for this instance
889
1510
  stateManager.dispose();
1511
+ dynamicErrorListeners.clear();
1512
+ lastError = null;
890
1513
 
891
1514
  const mapOnEl = elementInstances.get(rootEl);
892
1515
  if (mapOnEl) {
@@ -1077,5 +1700,7 @@ module.exports = {
1077
1700
  createControllerRuntime,
1078
1701
  queryScopedTargets,
1079
1702
  isTargetInControllerScope,
1703
+ watch,
1704
+ getActiveControllerContext,
1080
1705
  };
1081
1706