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.
- package/core/client/adapter/datastar.js +248 -51
- package/core/client/api.js +328 -22
- package/core/client/controller.js +921 -38
- package/core/client/debug.js +12 -12
- package/core/client/devtool.js +1239 -0
- package/core/client/forms.js +184 -21
- package/core/client/fragments.js +566 -72
- package/core/client/host.js +365 -0
- package/core/client/index.d.ts +37 -0
- package/core/client/index.js +190 -15
- package/core/client/lite.js +112 -4
- package/core/client/server-fragments.js +111 -24
- package/core/client/services.js +588 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +324 -1
- package/core/index.js +4 -0
- package/index.d.ts +523 -39
- package/index.js +7 -0
- package/package.json +7 -8
- package/src/bundle/index.js +175 -14
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +6 -18
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/banner.js +4 -1
- package/src/dev-reload/index.js +29 -1
- package/src/file-router.js +12 -8
- package/src/routing/mount-discovered-routes.js +9 -2
- package/src/server.js +42 -24
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
|
@@ -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
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const base =
|
|
53
|
-
return (base
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
207
|
-
?
|
|
208
|
-
: defineController(
|
|
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 (
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1559
|
+
const res = fn(ctx, event, decoratedEl);
|
|
753
1560
|
if (res && typeof res.then === 'function') {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
|