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.
- package/core/client/adapter/datastar.js +175 -28
- package/core/client/api.js +312 -8
- package/core/client/controller.js +655 -30
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +145 -2
- package/core/client/fragments.js +166 -10
- package/core/client/index.d.ts +19 -0
- package/core/client/index.js +47 -3
- package/core/client/lite.js +11 -0
- package/core/client/server-fragments.js +12 -3
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +137 -0
- package/core/index.js +4 -0
- package/index.d.ts +204 -14
- package/index.js +4 -0
- package/package.json +7 -8
- 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/file-router.js +12 -8
- package/src/routing/mount-discovered-routes.js +9 -2
- package/src/server.js +14 -24
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
|
@@ -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
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const base =
|
|
53
|
-
return (base
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
207
|
-
?
|
|
208
|
-
: defineController(
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|