@jinntec/fore 3.2.0 → 3.3.0

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
  /**
@@ -387,7 +465,7 @@ export class FxFore extends HTMLElement {
387
465
  return null;
388
466
  }
389
467
 
390
- _isReadyTarget(el) {
468
+ static _isReadyTarget(el) {
391
469
  return !!(
392
470
  el &&
393
471
  (el.ready === true ||
@@ -396,6 +474,17 @@ export class FxFore extends HTMLElement {
396
474
  );
397
475
  }
398
476
 
477
+ /**
478
+ * Resolves once `fore` has dispatched its initial `ready` event (or
479
+ * immediately, if it's already ready).
480
+ */
481
+ static waitUntilReady(fore) {
482
+ if (FxFore._isReadyTarget(fore)) {
483
+ return Promise.resolve();
484
+ }
485
+ return FxFore._waitForEvent(fore, 'ready', FxFore._isReadyTarget);
486
+ }
487
+
399
488
  /**
400
489
  * Collect all init gates derived from attributes.
401
490
  *
@@ -433,7 +522,7 @@ export class FxFore extends HTMLElement {
433
522
  return gates;
434
523
  }
435
524
 
436
- _waitForEvent(target, eventName, isSatisfiedFn = null) {
525
+ static _waitForEvent(target, eventName, isSatisfiedFn = null) {
437
526
  // If a caller provides an explicit satisfaction check, honor it first.
438
527
  if (typeof isSatisfiedFn === 'function' && isSatisfiedFn(target)) {
439
528
  FxFore._markInitEventSeen(target, eventName);
@@ -495,20 +584,20 @@ export class FxFore extends HTMLElement {
495
584
  _waitForInitGate({ event, targetSpec }) {
496
585
  // Direct targets
497
586
  if (targetSpec === 'self') {
498
- const satisfied = event === 'ready' ? t => this._isReadyTarget(t) : null;
499
- return this._waitForEvent(this, event, satisfied);
587
+ const satisfied = event === 'ready' ? t => FxFore._isReadyTarget(t) : null;
588
+ return FxFore._waitForEvent(this, event, satisfied);
500
589
  }
501
590
  if (targetSpec === 'document') {
502
- return this._waitForEvent(document, event);
591
+ return FxFore._waitForEvent(document, event);
503
592
  }
504
593
  if (targetSpec === 'window') {
505
- return this._waitForEvent(window, event);
594
+ return FxFore._waitForEvent(window, event);
506
595
  }
507
596
 
508
597
  // Special: closest fx-fore
509
598
  if (targetSpec === 'closest') {
510
599
  const recheckFn =
511
- event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
600
+ event === 'ready' ? () => FxFore._isReadyTarget(this.closest('fx-fore')) : null;
512
601
 
513
602
  const matchesFn = ev => {
514
603
  const t = ev.target;
@@ -522,7 +611,7 @@ export class FxFore extends HTMLElement {
522
611
  const selector = targetSpec;
523
612
 
524
613
  const recheckFn =
525
- event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
614
+ event === 'ready' ? () => FxFore._isReadyTarget(this._findBySelector(selector)) : null;
526
615
 
527
616
  if (typeof recheckFn === 'function' && recheckFn()) {
528
617
  return Promise.resolve();
@@ -627,7 +716,10 @@ export class FxFore extends HTMLElement {
627
716
  const libs = Array.from(this.querySelectorAll('fx-functionlib'));
628
717
  await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
629
718
 
719
+ this.debugInfo.modelConstructStartedAt = performance.now();
630
720
  await modelElement.modelConstruct();
721
+ this.debugInfo.modelConstructDoneAt = performance.now();
722
+
631
723
  console.log('varbindings ', this._instanceVarBindings);
632
724
  this._handleModelConstructDone();
633
725
  }
@@ -702,9 +794,8 @@ export class FxFore extends HTMLElement {
702
794
  // e.stopImmediatePropagation();
703
795
  },true);
704
796
  */
705
- this.ignoreExpressions = this.hasAttribute('ignore-expressions')
706
- ? this.getAttribute('ignore-expressions')
707
- : null;
797
+ const userIgnore = this.getAttribute('ignore-expressions');
798
+ this.ignoreExpressions = userIgnore ? `[pattern], ${userIgnore}` : '[pattern]';
708
799
 
709
800
  this.lazyRefresh = this.hasAttribute('refresh-on-view');
710
801
  if (this.lazyRefresh) {
@@ -945,6 +1036,11 @@ export class FxFore extends HTMLElement {
945
1036
  async refresh(force) {
946
1037
  // If we're already refreshing, do NOT drop the request.
947
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
+
948
1044
  if (this.isRefreshing) {
949
1045
  // keep "strongest" request: any true means hard refresh
950
1046
  this._pendingRefresh = this._pendingRefresh || force === true;
@@ -957,8 +1053,12 @@ export class FxFore extends HTMLElement {
957
1053
  this.isRefreshing = true;
958
1054
  this.isRefreshPhase = true;
959
1055
 
1056
+ const refreshStart = performance.now();
1057
+ const isFullRefresh = force === true || this.initialRun;
1058
+ const batchedCount = this.batchedNotifications.size;
1059
+
960
1060
  try {
961
- if (force === true || this.initialRun) {
1061
+ if (isFullRefresh) {
962
1062
  performance.mark('force-refresh-start');
963
1063
  console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
964
1064
  await Fore.refreshChildren(this, force);
@@ -985,6 +1085,15 @@ export class FxFore extends HTMLElement {
985
1085
  this.getModel().modelItems,
986
1086
  );
987
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
+
988
1097
  Fore.dispatch(this, 'refresh-done', {});
989
1098
 
990
1099
  const subFores = Array.from(this.querySelectorAll('fx-fore'));
@@ -1018,59 +1127,63 @@ export class FxFore extends HTMLElement {
1018
1127
  }
1019
1128
 
1020
1129
  /**
1021
- * 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.
1022
1134
  */
1023
- _processBatchedNotifications() {
1135
+ async _processBatchedNotifications() {
1024
1136
  if (this.batchedNotifications.size > 0) {
1025
1137
  console.log(`🔄 🎯 ### processing ${this.batchedNotifications.size} batched notifications`);
1026
1138
  console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
1027
1139
 
1028
- // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
1140
+ const refreshPromises = [];
1029
1141
 
1030
- // 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.
1031
1146
  this.batchedNotifications.forEach(entry => {
1032
- // console.log('batched update', entry);
1033
- // handle repeatitems created via data-ref
1034
1147
  if (entry.classList && entry.classList.contains('fx-repeatitem')) {
1035
- Fore.refreshChildren(entry, true);
1148
+ refreshPromises.push(Fore.refreshChildren(entry, true));
1036
1149
  }
1037
1150
  if (entry && typeof entry.refresh === 'function') {
1038
- // Entry is a Ui Element
1039
- // Force refresh for this whole subtree
1040
1151
  const uiElement = /** @type {import('./ui/UIElement.js').UIElement} */ (entry);
1041
1152
  if (!uiElement.ownerDocument.contains(uiElement)) {
1042
- // Something already removed this ui element. Skip.
1043
1153
  return;
1044
1154
  }
1045
- uiElement.refresh(true);
1155
+ refreshPromises.push(uiElement.refresh(true));
1046
1156
  }
1047
1157
  const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]'));
1048
- // loop nonrelevant elements
1049
1158
  if (nonrelevant) {
1050
- nonrelevant.forEach(entry => {
1051
- if (entry.refresh) {
1052
- entry.refresh();
1159
+ nonrelevant.forEach(el => {
1160
+ if (el.refresh) {
1161
+ refreshPromises.push(el.refresh());
1053
1162
  }
1054
1163
  });
1055
1164
  }
1056
1165
  if (entry.observers) {
1057
- // Item is a model item
1058
1166
  entry.observers.forEach(observer => {
1059
- // console.log('🔍 processing observer', observer);
1060
1167
  if (typeof observer.update === 'function') {
1061
- // console.log('updating observer', observer);
1062
1168
  observer.update(entry);
1063
1169
  }
1064
1170
  });
1065
1171
  }
1066
1172
  });
1067
1173
 
1068
- // Update template expressions after processing batched notifications
1069
- // This ensures template expressions are re-evaluated when data changes
1070
1174
  this._processTemplateExpressions();
1071
-
1072
- // Clear the batch
1073
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
+ }
1074
1187
  }
1075
1188
  }
1076
1189
 
@@ -1230,10 +1343,8 @@ export class FxFore extends HTMLElement {
1230
1343
  } // eslint-disable-next-line class-methods-use-this
1231
1344
  _getTemplateExpression(node) {
1232
1345
  if (this.ignoredNodes) {
1233
- if (node.nodeType === Node.ATTRIBUTE_NODE) {
1234
- node = node.ownerElement;
1235
- }
1236
- 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));
1237
1348
  if (found) return null;
1238
1349
  }
1239
1350
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1252,6 +1363,7 @@ export class FxFore extends HTMLElement {
1252
1363
  * @private
1253
1364
  */
1254
1365
  _handleModelConstructDone() {
1366
+ this.debugInfo.modelConstructDoneAt = performance.now();
1255
1367
  if (this.showConfirmation) {
1256
1368
  window.addEventListener('beforeunload', event => {
1257
1369
  if (this.dirtyState === dirtyStates.DIRTY) {
@@ -1418,6 +1530,7 @@ export class FxFore extends HTMLElement {
1418
1530
  // console.log(`### <<<<< ${this.id} ready >>>>>`);
1419
1531
 
1420
1532
  Fore.dispatch(this, 'ready', {});
1533
+ this.debugInfo.readyAt = performance.now();
1421
1534
  // console.log('dataChanged', FxModel.dataChanged);
1422
1535
  this.markAsClean();
1423
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
+ }