@jinntec/fore 3.2.1 → 3.3.1

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/src/fx-fore.js CHANGED
@@ -102,6 +102,7 @@ export class FxFore extends HTMLElement {
102
102
  },
103
103
  /**
104
104
  * merge-partial
105
+ * @deprecated
105
106
  */
106
107
  mergePartial: {
107
108
  type: Boolean,
@@ -346,15 +347,92 @@ export class FxFore extends HTMLElement {
346
347
  this.initialRun = true;
347
348
  this._scanForNewTemplateExpressionsNextRefresh = false;
348
349
  this.repeatsFromAttributesCreated = false;
350
+ /*
349
351
  this.validateOn = this.hasAttribute('validate-on')
350
352
  ? this.getAttribute('validate-on')
351
353
  : 'update';
354
+ */
352
355
  // this.mergePartial = this.hasAttribute('merge-partial')? true:false;
353
356
  this.mergePartial = false;
354
357
  this.createNodes = this.hasAttribute('create-nodes') ? true : false;
355
358
  this._localNamesWithChanges = new Set();
356
359
  this.setAttribute('role', 'form'); // set aria role
357
360
  this._pendingRefresh = false;
361
+
362
+ this.debugInfo = {
363
+ id: this.id || null,
364
+ debugId: crypto.randomUUID ? crypto.randomUUID() : `fore-${Date.now()}`,
365
+ createdAt: performance.now(),
366
+ readyAt: null,
367
+ modelConstructStartedAt: null,
368
+ modelConstructDoneAt: null,
369
+ refreshCount: 0,
370
+ lastRefreshAt: null,
371
+ lastRefreshForce: null,
372
+ lastRefresh: null,
373
+ };
374
+ }
375
+
376
+ getDebugInfo() {
377
+ return {
378
+ ...this.debugInfo,
379
+ id: this.id || null,
380
+ ready: this.ready,
381
+ lazyRefresh: this.lazyRefresh,
382
+ createNodes: this.createNodes,
383
+
384
+ initOn: this.getAttribute('init-on') || null,
385
+ initOnTarget: this.getAttribute('init-on-target') || null,
386
+ ignoreExpressions: this.getAttribute('ignore-expressions') || null,
387
+
388
+ model: this.model?.getDebugInfo?.() || null,
389
+ };
390
+ }
391
+
392
+ getDebugSnapshot(options = {}) {
393
+ const model = this.model;
394
+
395
+ const debugRefElements = Array.from(this.querySelectorAll('[ref]'))
396
+ .filter(element => typeof element.getDebugInfo === 'function');
397
+
398
+ const bindingElements = debugRefElements
399
+ .filter(element => element.localName === 'fx-bind');
400
+
401
+ const boundElementNames = new Set([
402
+ 'fx-control',
403
+ 'fx-output',
404
+ 'fx-upload',
405
+ 'fx-group',
406
+ 'fx-repeat',
407
+ 'fx-switch',
408
+ ]);
409
+
410
+ const boundUiElements = debugRefElements
411
+ .filter(element => boundElementNames.has(element.localName));
412
+
413
+ const bindings = bindingElements.map(element => element.getDebugInfo());
414
+
415
+ const boundElements = boundUiElements.map(element => element.getDebugInfo());
416
+
417
+ const submissions = Array.from(this.querySelectorAll('fx-submission'))
418
+ .filter(element => typeof element.getDebugInfo === 'function')
419
+ .map(element => element.getDebugInfo());
420
+
421
+ return {
422
+ fore: this.getDebugInfo?.() || null,
423
+
424
+ model:
425
+ model?.getDebugInfo?.({
426
+ includeGraphs: options.includeGraphs === true,
427
+ }) || null,
428
+
429
+ instances: model?.instances?.map(instance => instance.getDebugInfo?.()) || [],
430
+ modelItems: model?.modelItems?.map(item => item.getDebugInfo?.()) || [],
431
+
432
+ bindings,
433
+ boundElements,
434
+ submissions
435
+ };
358
436
  }
359
437
 
360
438
  /**
@@ -638,7 +716,10 @@ export class FxFore extends HTMLElement {
638
716
  const libs = Array.from(this.querySelectorAll('fx-functionlib'));
639
717
  await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
640
718
 
719
+ this.debugInfo.modelConstructStartedAt = performance.now();
641
720
  await modelElement.modelConstruct();
721
+ this.debugInfo.modelConstructDoneAt = performance.now();
722
+
642
723
  console.log('varbindings ', this._instanceVarBindings);
643
724
  this._handleModelConstructDone();
644
725
  }
@@ -713,9 +794,8 @@ export class FxFore extends HTMLElement {
713
794
  // e.stopImmediatePropagation();
714
795
  },true);
715
796
  */
716
- this.ignoreExpressions = this.hasAttribute('ignore-expressions')
717
- ? this.getAttribute('ignore-expressions')
718
- : null;
797
+ const userIgnore = this.getAttribute('ignore-expressions');
798
+ this.ignoreExpressions = userIgnore ? `[pattern], ${userIgnore}` : '[pattern]';
719
799
 
720
800
  this.lazyRefresh = this.hasAttribute('refresh-on-view');
721
801
  if (this.lazyRefresh) {
@@ -956,6 +1036,11 @@ export class FxFore extends HTMLElement {
956
1036
  async refresh(force) {
957
1037
  // If we're already refreshing, do NOT drop the request.
958
1038
  // Queue a hard refresh and return a promise that resolves when the next refresh finishes.
1039
+
1040
+ this.debugInfo.refreshCount += 1;
1041
+ this.debugInfo.lastRefreshAt = performance.now();
1042
+ this.debugInfo.lastRefreshForce = !!force;
1043
+
959
1044
  if (this.isRefreshing) {
960
1045
  // keep "strongest" request: any true means hard refresh
961
1046
  this._pendingRefresh = this._pendingRefresh || force === true;
@@ -968,8 +1053,12 @@ export class FxFore extends HTMLElement {
968
1053
  this.isRefreshing = true;
969
1054
  this.isRefreshPhase = true;
970
1055
 
1056
+ const refreshStart = performance.now();
1057
+ const isFullRefresh = force === true || this.initialRun;
1058
+ const batchedCount = this.batchedNotifications.size;
1059
+
971
1060
  try {
972
- if (force === true || this.initialRun) {
1061
+ if (isFullRefresh) {
973
1062
  performance.mark('force-refresh-start');
974
1063
  console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
975
1064
  await Fore.refreshChildren(this, force);
@@ -996,6 +1085,15 @@ export class FxFore extends HTMLElement {
996
1085
  this.getModel().modelItems,
997
1086
  );
998
1087
 
1088
+ // Record timing before dispatching 'refresh-done' since listeners (eg. fx-debugger)
1089
+ // may synchronously read this.debugInfo.lastRefresh in response to the event.
1090
+ this.debugInfo.lastRefresh = {
1091
+ timestamp: performance.now(),
1092
+ kind: isFullRefresh ? 'full' : 'partial',
1093
+ durationMs: performance.now() - refreshStart,
1094
+ batchedCount,
1095
+ };
1096
+
999
1097
  Fore.dispatch(this, 'refresh-done', {});
1000
1098
 
1001
1099
  const subFores = Array.from(this.querySelectorAll('fx-fore'));
@@ -1029,59 +1127,63 @@ export class FxFore extends HTMLElement {
1029
1127
  }
1030
1128
 
1031
1129
  /**
1032
- * Process all batched notifications at the end of the refresh phase
1130
+ * Process all batched notifications at the end of the refresh phase.
1131
+ * Async so that all control refresh() calls (which are themselves async) are awaited
1132
+ * before refresh-done fires — prevents _isRefreshing from being true when the
1133
+ * next user input event arrives.
1033
1134
  */
1034
- _processBatchedNotifications() {
1135
+ async _processBatchedNotifications() {
1035
1136
  if (this.batchedNotifications.size > 0) {
1036
1137
  console.log(`🔄 🎯 ### processing ${this.batchedNotifications.size} batched notifications`);
1037
1138
  console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
1038
1139
 
1039
- // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
1140
+ const refreshPromises = [];
1040
1141
 
1041
- // Process all batched notifications
1142
+ // Process all batched notifications.
1143
+ // Note: Set.forEach visits items added during iteration (they are appended to the end),
1144
+ // so controls added via observer.update() (which calls addToBatchedNotifications) are
1145
+ // also visited and their refresh() promises collected.
1042
1146
  this.batchedNotifications.forEach(entry => {
1043
- // console.log('batched update', entry);
1044
- // handle repeatitems created via data-ref
1045
1147
  if (entry.classList && entry.classList.contains('fx-repeatitem')) {
1046
- Fore.refreshChildren(entry, true);
1148
+ refreshPromises.push(Fore.refreshChildren(entry, true));
1047
1149
  }
1048
1150
  if (entry && typeof entry.refresh === 'function') {
1049
- // Entry is a Ui Element
1050
- // Force refresh for this whole subtree
1051
1151
  const uiElement = /** @type {import('./ui/UIElement.js').UIElement} */ (entry);
1052
1152
  if (!uiElement.ownerDocument.contains(uiElement)) {
1053
- // Something already removed this ui element. Skip.
1054
1153
  return;
1055
1154
  }
1056
- uiElement.refresh(true);
1155
+ refreshPromises.push(uiElement.refresh(true));
1057
1156
  }
1058
1157
  const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]'));
1059
- // loop nonrelevant elements
1060
1158
  if (nonrelevant) {
1061
- nonrelevant.forEach(entry => {
1062
- if (entry.refresh) {
1063
- entry.refresh();
1159
+ nonrelevant.forEach(el => {
1160
+ if (el.refresh) {
1161
+ refreshPromises.push(el.refresh());
1064
1162
  }
1065
1163
  });
1066
1164
  }
1067
1165
  if (entry.observers) {
1068
- // Item is a model item
1069
1166
  entry.observers.forEach(observer => {
1070
- // console.log('🔍 processing observer', observer);
1071
1167
  if (typeof observer.update === 'function') {
1072
- // console.log('updating observer', observer);
1073
1168
  observer.update(entry);
1074
1169
  }
1075
1170
  });
1076
1171
  }
1077
1172
  });
1078
1173
 
1079
- // Update template expressions after processing batched notifications
1080
- // This ensures template expressions are re-evaluated when data changes
1081
1174
  this._processTemplateExpressions();
1082
-
1083
- // Clear the batch
1084
1175
  this.batchedNotifications.clear();
1176
+
1177
+ if (refreshPromises.length > 0) {
1178
+ await Promise.all(refreshPromises);
1179
+ }
1180
+
1181
+ // Items added to batchedNotifications during the async Promise.all phase
1182
+ // (e.g. by a second event listener that fired after the batch was cleared)
1183
+ // are picked up here so they aren't lost.
1184
+ if (this.batchedNotifications.size > 0) {
1185
+ await this._processBatchedNotifications();
1186
+ }
1085
1187
  }
1086
1188
  }
1087
1189
 
@@ -1241,10 +1343,8 @@ export class FxFore extends HTMLElement {
1241
1343
  } // eslint-disable-next-line class-methods-use-this
1242
1344
  _getTemplateExpression(node) {
1243
1345
  if (this.ignoredNodes) {
1244
- if (node.nodeType === Node.ATTRIBUTE_NODE) {
1245
- node = node.ownerElement;
1246
- }
1247
- const found = this.ignoredNodes.find(n => n.contains(node));
1346
+ const checkNode = node.nodeType === Node.ATTRIBUTE_NODE ? node.ownerElement : node;
1347
+ const found = this.ignoredNodes.find(n => n.contains(checkNode));
1248
1348
  if (found) return null;
1249
1349
  }
1250
1350
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1263,6 +1363,7 @@ export class FxFore extends HTMLElement {
1263
1363
  * @private
1264
1364
  */
1265
1365
  _handleModelConstructDone() {
1366
+ this.debugInfo.modelConstructDoneAt = performance.now();
1266
1367
  if (this.showConfirmation) {
1267
1368
  window.addEventListener('beforeunload', event => {
1268
1369
  if (this.dirtyState === dirtyStates.DIRTY) {
@@ -1429,6 +1530,7 @@ export class FxFore extends HTMLElement {
1429
1530
  // console.log(`### <<<<< ${this.id} ready >>>>>`);
1430
1531
 
1431
1532
  Fore.dispatch(this, 'ready', {});
1533
+ this.debugInfo.readyAt = performance.now();
1432
1534
  // console.log('dataChanged', FxModel.dataChanged);
1433
1535
  this.markAsClean();
1434
1536
 
@@ -9,7 +9,11 @@ async function handleResponse(fxInstance, response) {
9
9
  alert(`response status: ${status} - failed to load data for '${fxInstance.src}' - stopping.`);
10
10
  throw new Error(`failed to load data - status: ${status}`);
11
11
  }
12
- let responseContentType = response.headers.get('content-type').split(';')[0].trim().toLowerCase();
12
+ const responseContentType = response.headers
13
+ .get('content-type')
14
+ .split(';')[0]
15
+ .trim()
16
+ .toLowerCase();
13
17
 
14
18
  if (responseContentType.startsWith('text/html')) {
15
19
  return response.text().then(result => new DOMParser().parseFromString(result, 'text/html'));
@@ -49,6 +53,37 @@ export class FxInstance extends HTMLElement {
49
53
 
50
54
  // JSON facade (only relevant for JSON instances)
51
55
  this.domFacade = null;
56
+
57
+ this.debugInfo = {
58
+ debugId: `instance-${Math.random().toString(36).slice(2, 9)}`,
59
+ createdAt: performance.now(),
60
+ initializedAt: null,
61
+ loadCount: 0,
62
+ /*
63
+ mutationCount: 0,
64
+ lastMutationAt: null,
65
+ */
66
+ };
67
+ }
68
+
69
+ getDebugInfo() {
70
+ const defaultContext = this.getDefaultContext?.();
71
+
72
+ return {
73
+ ...this.debugInfo,
74
+ id: this.getAttribute('id') || null,
75
+ instanceId: this.instanceId,
76
+ type: this.type,
77
+ src: this.getAttribute('src') || null,
78
+ shared: this.hasAttribute('shared'),
79
+ hasData: !!this.instanceData,
80
+ hasNodeset: !!this.nodeset,
81
+ defaultContextType: defaultContext?.nodeType
82
+ ? 'xml-node'
83
+ : defaultContext?.__jsonlens__
84
+ ? 'json-lens'
85
+ : typeof defaultContext,
86
+ };
52
87
  }
53
88
 
54
89
  connectedCallback() {
@@ -61,7 +96,9 @@ export class FxInstance extends HTMLElement {
61
96
  // If the author did not provide an id on that first instance, we set id="default".
62
97
  // If the author provided an id on that first instance, we use that id instead.
63
98
  const parentModel =
64
- this.parentNode && this.parentNode.nodeName && this.parentNode.nodeName.toUpperCase() === 'FX-MODEL'
99
+ this.parentNode &&
100
+ this.parentNode.nodeName &&
101
+ this.parentNode.nodeName.toUpperCase() === 'FX-MODEL'
65
102
  ? this.parentNode
66
103
  : null;
67
104
 
@@ -92,7 +129,9 @@ export class FxInstance extends HTMLElement {
92
129
  }
93
130
  }
94
131
 
95
- this.credentials = this.hasAttribute('credentials') ? this.getAttribute('credentials') : 'same-origin';
132
+ this.credentials = this.hasAttribute('credentials')
133
+ ? this.getAttribute('credentials')
134
+ : 'same-origin';
96
135
  if (!['same-origin', 'include', 'omit'].includes(this.credentials)) {
97
136
  console.error(
98
137
  `fx-submission: the value of credentials is not valid. Expected 'same-origin', 'include' or 'omit' but got '${this.credentials}'`,
@@ -144,6 +183,8 @@ export class FxInstance extends HTMLElement {
144
183
 
145
184
  // Signal structure mutation (used by fx-fore for refresh decisions)
146
185
  this.dispatchEvent(new CustomEvent('path-mutated', { bubbles: true, composed: true }));
186
+ // this.debugInfo.mutationCount += 1;
187
+ // this.debugInfo.lastMutationAt = performance.now();
147
188
  }
148
189
 
149
190
  /**
@@ -162,6 +203,8 @@ export class FxInstance extends HTMLElement {
162
203
  }
163
204
 
164
205
  reset() {
206
+ // this.debugInfo.mutationCount += 1;
207
+ // this.debugInfo.lastMutationAt = performance.now();
165
208
  // use the setter so nodeset is rebuilt for JSON too
166
209
  if (this.originalInstance && this.type === 'xml') {
167
210
  this.instanceData = this.originalInstance.cloneNode(true);
@@ -193,6 +236,8 @@ export class FxInstance extends HTMLElement {
193
236
  * legacy setter API: keep it, but forward to instanceData setter
194
237
  */
195
238
  setInstanceData(data) {
239
+ // this.debugInfo.mutationCount += 1;
240
+ // this.debugInfo.lastMutationAt = performance.now();
196
241
  this.instanceData = data;
197
242
  }
198
243
 
@@ -224,6 +269,8 @@ export class FxInstance extends HTMLElement {
224
269
  } else if (this.childNodes.length !== 0) {
225
270
  this._useInlineData();
226
271
  }
272
+ this.debugInfo.initializedAt = performance.now();
273
+ this.debugInfo.loadCount += 1;
227
274
  }
228
275
 
229
276
  createInstanceData() {
@@ -339,4 +386,4 @@ export class FxInstance extends HTMLElement {
339
386
 
340
387
  if (!customElements.get('fx-instance')) {
341
388
  customElements.define('fx-instance', FxInstance);
342
- }
389
+ }
package/src/fx-model.js CHANGED
@@ -45,6 +45,127 @@ export class FxModel extends HTMLElement {
45
45
  * @type {import('./fx-bind.js').FxBind[]}
46
46
  */
47
47
  this.binds = [];
48
+
49
+ this.debugInfo = {
50
+ debugId: `model-${Math.random().toString(36).slice(2, 9)}`,
51
+ createdAt: performance.now(),
52
+ modelConstructCount: 0,
53
+ updateModelCount: 0,
54
+ rebuildCount: 0,
55
+ recalculateCount: 0,
56
+ revalidateCount: 0,
57
+ lastUpdateAt: null,
58
+ lastCycle: null,
59
+ };
60
+ }
61
+
62
+ getDebugInfo(options = {}) {
63
+ const info = {
64
+ ...this.debugInfo,
65
+ constructed: this.modelConstructed,
66
+ inited: this.inited,
67
+ instanceCount: this.instances.length,
68
+ modelItemCount: this.modelItems.length,
69
+ instances: this.instances.map(instance => instance.getDebugInfo?.()),
70
+ modelItems: this.modelItems.map(item => item.getDebugInfo?.()),
71
+ };
72
+
73
+ if (options.includeGraphs === true) {
74
+ info.graphs = this.getDebugGraphInfo();
75
+ }
76
+
77
+ return info;
78
+ }
79
+
80
+ getDebugGraphInfo() {
81
+ return {
82
+ computes: this.computes,
83
+ mainGraph: this.getDebugGraphSummary(this.mainGraph),
84
+ subGraph: this.getDebugGraphSummary(this.subgraph),
85
+ };
86
+ }
87
+
88
+ getDebugGraphSummary(graph) {
89
+ if (!graph) {
90
+ return null;
91
+ }
92
+
93
+ const nodes = Object.keys(graph.nodes || {});
94
+ const outgoingEdges = graph.outgoingEdges || {};
95
+ const incomingEdges = graph.incomingEdges || {};
96
+ const calculationOrder = this.getDebugGraphOrder(graph);
97
+
98
+ return {
99
+ nodeCount: typeof graph.size === 'function' ? graph.size() : nodes.length,
100
+ edgeCount: this.getDebugEdgeCount(outgoingEdges),
101
+ outgoingEdgeCount: this.getDebugEdgeCount(outgoingEdges),
102
+ incomingEdgeCount: this.getDebugEdgeCount(incomingEdges),
103
+ computeNodeCount: nodes.filter(node => typeof node === 'string' && node.includes(':')).length,
104
+ calculationOrderCount: calculationOrder.length,
105
+ calculationOrder: calculationOrder.map((path, index) =>
106
+ this.getDebugGraphNodeInfo(graph, path, index),
107
+ ),
108
+ };
109
+ }
110
+
111
+ getDebugGraphOrder(graph) {
112
+ if (!graph || typeof graph.overallOrder !== 'function') {
113
+ return [];
114
+ }
115
+
116
+ try {
117
+ return graph.overallOrder(false);
118
+ } catch (error) {
119
+ return [];
120
+ }
121
+ }
122
+
123
+ getDebugEdgeCount(edgeMap = {}) {
124
+ return Object.values(edgeMap).reduce((count, edges) => {
125
+ return count + (Array.isArray(edges) ? edges.length : 0);
126
+ }, 0);
127
+ }
128
+
129
+ getDebugGraphNodeInfo(graph, path, index = 0) {
130
+ let data = null;
131
+
132
+ try {
133
+ data = graph.getNodeData(path);
134
+ } catch (error) {
135
+ data = null;
136
+ }
137
+
138
+ const basePath =
139
+ typeof path === 'string' && path.includes(':')
140
+ ? path.substring(0, path.indexOf(':'))
141
+ : path;
142
+
143
+ const facet =
144
+ typeof path === 'string' && path.includes(':')
145
+ ? path.substring(path.indexOf(':') + 1)
146
+ : null;
147
+
148
+ const modelItem = this.getModelItem(basePath);
149
+
150
+ return {
151
+ index: index + 1,
152
+ path,
153
+ basePath,
154
+ facet,
155
+ isCompute: !!facet,
156
+ ref: modelItem?.ref || null,
157
+ instanceId: modelItem?.instanceId || null,
158
+ value: modelItem?.value,
159
+ dataType: data?.__jsonlens__
160
+ ? 'json-lens'
161
+ : data?.nodeType
162
+ ? 'xml-node'
163
+ : data === null || data === undefined
164
+ ? null
165
+ : typeof data,
166
+ dependencies: graph.outgoingEdges?.[path] || [],
167
+ dependants: graph.incomingEdges?.[path] || [],
168
+ };
48
169
  }
49
170
 
50
171
  /**
@@ -205,6 +326,7 @@ export class FxModel extends HTMLElement {
205
326
  */
206
327
  async modelConstruct() {
207
328
  console.info(`📌 model-construct for #${this.parentNode.id}`);
329
+ this.debugInfo.modelConstructCount += 1;
208
330
 
209
331
  // this.dispatchEvent(new CustomEvent('model-construct', { detail: this }));
210
332
  Fore.dispatch(this, 'model-construct', { model: this });
@@ -284,6 +406,7 @@ export class FxModel extends HTMLElement {
284
406
  // Tabula rasa for computed facets; keep identity (boundControls/observers)
285
407
  mi.readonly = ModelItem.READONLY_DEFAULT;
286
408
  mi.relevant = ModelItem.RELEVANT_DEFAULT;
409
+ mi._parentModelItem = undefined;
287
410
  mi.required = ModelItem.REQUIRED_DEFAULT;
288
411
  mi.constraint = ModelItem.CONSTRAINT_DEFAULT;
289
412
  mi.type = ModelItem.TYPE_DEFAULT;
@@ -361,7 +484,10 @@ export class FxModel extends HTMLElement {
361
484
  * update action triggering the update cycle
362
485
  */
363
486
  updateModel() {
364
- // console.time('updateModel');
487
+ this.debugInfo.updateModelCount += 1;
488
+ this.debugInfo.lastUpdateAt = performance.now();
489
+
490
+ const rebuildStart = performance.now();
365
491
  this.rebuild();
366
492
  /*
367
493
  if (this.skipUpdate){
@@ -369,11 +495,21 @@ export class FxModel extends HTMLElement {
369
495
  return;
370
496
  }
371
497
  */
498
+ const recalculateStart = performance.now();
372
499
  this.recalculate();
500
+ const revalidateStart = performance.now();
373
501
  this.revalidate();
374
- // console.log('updateModel finished with modelItems ', this.modelItems);
375
-
376
- // console.timeEnd('updateModel');
502
+ const revalidateEnd = performance.now();
503
+
504
+ this.debugInfo.lastCycle = {
505
+ timestamp: revalidateEnd,
506
+ rebuildMs: recalculateStart - rebuildStart,
507
+ recalculateMs: revalidateStart - recalculateStart,
508
+ revalidateMs: revalidateEnd - revalidateStart,
509
+ totalMs: revalidateEnd - rebuildStart,
510
+ computes: this.computes || 0,
511
+ modelItemCount: this.modelItems.length,
512
+ };
377
513
  }
378
514
 
379
515
  /**
@@ -421,6 +557,7 @@ export class FxModel extends HTMLElement {
421
557
 
422
558
  rebuild() {
423
559
  console.log(`🔷 rebuild() '${this.fore.id}'`);
560
+ this.debugInfo.rebuildCount += 1;
424
561
 
425
562
  // Build a lookup for existing ModelItems so we can reuse them by path (approach A)
426
563
  const prevItems = Array.isArray(this.modelItems) ? this.modelItems : [];
@@ -459,6 +596,7 @@ export class FxModel extends HTMLElement {
459
596
  * todo: use 'changed' flag on modelItems to determine subgraph for recalculation. Flag already exists but is not used.
460
597
  */
461
598
  async recalculate() {
599
+ this.debugInfo.recalculateCount += 1;
462
600
  if (!this.mainGraph) {
463
601
  return;
464
602
  }
@@ -629,6 +767,36 @@ export class FxModel extends HTMLElement {
629
767
  this.computes += 1;
630
768
  }
631
769
  }
770
+
771
+ _getNativeValidity(widget) {
772
+ if (!widget?.validity) return true;
773
+
774
+ let nativeValid = widget.validity.valid;
775
+
776
+ // Browsers do not consistently report minlength/maxlength violations
777
+ // for values assigned programmatically. Fore values are often updated
778
+ // programmatically, so enforce these two constraints explicitly.
779
+ const value = widget.value ?? '';
780
+
781
+ const minlength = widget.getAttribute('minlength');
782
+ if (minlength !== null && value !== '') {
783
+ const min = Number.parseInt(minlength, 10);
784
+ if (!Number.isNaN(min) && value.length < min) {
785
+ nativeValid = false;
786
+ }
787
+ }
788
+
789
+ const maxlength = widget.getAttribute('maxlength');
790
+ if (maxlength !== null) {
791
+ const max = Number.parseInt(maxlength, 10);
792
+ if (!Number.isNaN(max) && value.length > max) {
793
+ nativeValid = false;
794
+ }
795
+ }
796
+
797
+ return nativeValid;
798
+ }
799
+
632
800
  /**
633
801
  * Iterates all modelItems to calculate the validation status.
634
802
  *
@@ -646,6 +814,7 @@ export class FxModel extends HTMLElement {
646
814
  *
647
815
  */
648
816
  revalidate() {
817
+ this.debugInfo.revalidateCount += 1;
649
818
  if (this.modelItems.length === 0) return true;
650
819
 
651
820
  console.log(`🔷🔷🔷 revalidate() '${this.fore.id}'`);
@@ -706,6 +875,22 @@ export class FxModel extends HTMLElement {
706
875
  }
707
876
  }
708
877
  });
878
+ // Native browser constraint validation — read ValidityState without side-effects.
879
+ // widget.validity.valid is a live property; no events are fired (unlike checkValidity()).
880
+ // Use querySelector instead of getWidget() to avoid DOM mutation (getWidget creates a
881
+ // fallback input if none exists).
882
+ this.fore.querySelectorAll('fx-control').forEach(control => {
883
+ if (!control.modelItem) return;
884
+ const widget = control.querySelector('.widget, input');
885
+ if (!widget?.validity) return;
886
+ const nativeValid = this._getNativeValidity(widget);
887
+ if (control.modelItem.nativeValid !== nativeValid) {
888
+ control.modelItem.nativeValid = nativeValid;
889
+ control.modelItem.notify();
890
+ }
891
+ if (!nativeValid) valid = false;
892
+ });
893
+
709
894
  console.log('modelItems after revalidate: ', this.modelItems);
710
895
  console.log('changed after revalidate: ', this.changed);
711
896
  console.log(
@@ -848,6 +1033,7 @@ export class FxModel extends HTMLElement {
848
1033
  const result = this.instances[0].evalXPath(bindingExpr);
849
1034
  return result;
850
1035
  }
1036
+
851
1037
  }
852
1038
 
853
1039
  if (!customElements.get('fx-model')) {
@@ -70,6 +70,35 @@ export class FxSubmission extends ForeElementMixin {
70
70
  this.shadowRoot.innerHTML = this.renderHTML();
71
71
  }
72
72
 
73
+ getDebugInfo() {
74
+ return {
75
+ localName: this.localName,
76
+ id: this.id || null,
77
+
78
+ method: this.getAttribute('method') || null,
79
+ url: this.getAttribute('url') || null,
80
+ action: this.getAttribute('action') || null,
81
+ resource: this.getAttribute('resource') || null,
82
+
83
+ ref: this.getAttribute('ref') || null,
84
+ instance: this.getAttribute('instance') || null,
85
+ targetref: this.getAttribute('targetref') || null,
86
+ target: this.getAttribute('target') || null,
87
+
88
+ replace: this.getAttribute('replace') || null,
89
+ mediatype: this.getAttribute('mediatype') || null,
90
+ encoding: this.getAttribute('encoding') || null,
91
+ serialization: this.getAttribute('serialization') || null,
92
+
93
+ validate: this.getAttribute('validate') || null,
94
+ relevant: this.getAttribute('relevant') || null,
95
+
96
+ hasResponse: Boolean(this.response),
97
+ responseStatus: this.response?.status || null,
98
+ responseStatusText: this.response?.statusText || null,
99
+ };
100
+ }
101
+
73
102
  // eslint-disable-next-line class-methods-use-this
74
103
  renderHTML() {
75
104
  return `