@jinntec/fore 2.8.0 → 3.0.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
@@ -20,6 +20,12 @@ const dirtyStates = {
20
20
  CLEAN: 'clean',
21
21
  DIRTY: 'dirty',
22
22
  };
23
+ async function waitForFunctionLibs(rootEl) {
24
+ const libs = Array.from(rootEl.querySelectorAll('fx-functionlib'));
25
+ await Promise.all(
26
+ libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve()))
27
+ );
28
+ }
23
29
 
24
30
  /**
25
31
  * Main class for Fore.Outermost container element for each Fore application.
@@ -282,6 +288,7 @@ export class FxFore extends HTMLElement {
282
288
  this.createNodes = this.hasAttribute('create-nodes') ? true : false;
283
289
  this._localNamesWithChanges = new Set();
284
290
  this.setAttribute('role', 'form'); // set aria role
291
+ this._pendingRefresh = false;
285
292
  }
286
293
 
287
294
  /**
@@ -549,7 +556,13 @@ export class FxFore extends HTMLElement {
549
556
  }
550
557
  })(this);
551
558
 
559
+ // Ensure all function libraries are loaded/registered before model construction,
560
+ // so binds/calculate/XPath evaluations can safely call them.
561
+ const libs = Array.from(this.querySelectorAll('fx-functionlib'));
562
+ await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
563
+
552
564
  await modelElement.modelConstruct();
565
+ console.log("varbindings ",this._instanceVarBindings);
553
566
  this._handleModelConstructDone();
554
567
  }
555
568
 
@@ -663,6 +676,79 @@ export class FxFore extends HTMLElement {
663
676
  }
664
677
  }
665
678
 
679
+ /**
680
+ * Ensure there is an fx-var for each fx-instance in this fx-fore's fx-model scope.
681
+ *
682
+ * - For instances with an @id, create `$id` with value `instance('id')`.
683
+ * - For the first instance WITHOUT an @id, create `$default` with value `instance()`.
684
+ * - IMPORTANT: if an instance has id="default", we STILL bind `$default` to `instance()`
685
+ * (avoids recursion / stack overflow during fx-var refresh in some cycles).
686
+ *
687
+ * Vars are inserted as direct children of `<fx-fore>` immediately before `<fx-model>`.
688
+ * The method is idempotent.
689
+ */
690
+ _ensureInstanceVars() {
691
+ if (this.__instanceVarsEnsured) return;
692
+ this.__instanceVarsEnsured = true;
693
+
694
+ // Resolve this fx-fore's own fx-model (not nested ones)
695
+ const model = this.querySelector(':scope > fx-model');
696
+ if (!model) return;
697
+
698
+ // Collect instances that are direct children of this model (doc order)
699
+ const instances = Array.from(model.querySelectorAll(':scope > fx-instance'));
700
+
701
+ // Collect existing fx-var names at fx-fore scope (author-defined and previously generated)
702
+ const existingVars = new Set(
703
+ Array.from(this.querySelectorAll(':scope > fx-var'))
704
+ .map(v => (v.getAttribute('name') || '').trim())
705
+ .filter(Boolean),
706
+ );
707
+
708
+ let defaultAssigned = false;
709
+
710
+ for (const inst of instances) {
711
+ const rawId = (inst.getAttribute('id') || '').trim();
712
+
713
+ // First id-less instance => $default = instance()
714
+ if (!rawId) {
715
+ if (defaultAssigned) continue;
716
+ defaultAssigned = true;
717
+
718
+ const name = 'default';
719
+ if (existingVars.has(name)) continue;
720
+
721
+ const fxVar = document.createElement('fx-var');
722
+ fxVar.setAttribute('name', name);
723
+ fxVar.setAttribute('value', 'instance()');
724
+ fxVar.setAttribute('data-generated', 'instance-var');
725
+
726
+ this.insertBefore(fxVar, model);
727
+ existingVars.add(name);
728
+ continue;
729
+ }
730
+
731
+ // Normal id-based instance var
732
+ const name = rawId;
733
+ if (existingVars.has(name)) continue;
734
+
735
+ const fxVar = document.createElement('fx-var');
736
+ fxVar.setAttribute('name', name);
737
+
738
+ // IMPORTANT: avoid `instance('default')` recursion in fx-var refresh
739
+ if (name === 'default') {
740
+ fxVar.setAttribute('value', 'instance()');
741
+ } else {
742
+ fxVar.setAttribute('value', `instance('${name}')`);
743
+ }
744
+
745
+ fxVar.setAttribute('data-generated', 'instance-var');
746
+
747
+ this.insertBefore(fxVar, model);
748
+ existingVars.add(name);
749
+ }
750
+ }
751
+
666
752
  _injectDevtools() {
667
753
  if (this.ownerDocument.querySelector('fx-devtools')) {
668
754
  // There's already a devtools, so we can ignore this one.
@@ -702,9 +788,11 @@ export class FxFore extends HTMLElement {
702
788
  }
703
789
 
704
790
  markAsClean() {
791
+ console.log('marking as clean', this);
705
792
  this.addEventListener(
706
793
  'value-changed',
707
794
  () => {
795
+ console.log('MARK as modified', this)
708
796
  this.dirtyState = dirtyStates.DIRTY;
709
797
  this.classList.toggle('fx-modified')
710
798
  },
@@ -712,6 +800,7 @@ export class FxFore extends HTMLElement {
712
800
  );
713
801
  this.dirtyState = dirtyStates.CLEAN;
714
802
  this.classList.remove('fx-modified');
803
+ this.querySelectorAll('.visited').forEach(el => el.classList.remove('visited'));
715
804
  }
716
805
 
717
806
  /**
@@ -782,89 +871,73 @@ export class FxFore extends HTMLElement {
782
871
  /**
783
872
  * @param {(boolean|{reason:'index-function'})} [force]fx-fore
784
873
  */
874
+ /**
875
+ * @param {(boolean|{reason:'index-function'})} [force]
876
+ */
877
+ /**
878
+ * @param {(boolean|{reason:'index-function'})} [force]
879
+ */
785
880
  async refresh(force) {
881
+ // If we're already refreshing, do NOT drop the request.
882
+ // Queue a hard refresh and return a promise that resolves when the next refresh finishes.
786
883
  if (this.isRefreshing) {
787
- return;
788
- }
884
+ // keep "strongest" request: any true means hard refresh
885
+ this._pendingRefresh = this._pendingRefresh || force === true;
789
886
 
790
- /*
791
- if (force !== true && this._localNamesWithChanges.size > 0) {
792
- force = {
793
- ...(force || { reason: undefined }),
794
- elementLocalnamesWithChanges: Array.from(this._localNamesWithChanges),
795
- };
796
- this._localNamesWithChanges.clear();
887
+ return new Promise(resolve => {
888
+ this.addEventListener('refresh-done', () => resolve(), { once: true });
889
+ });
797
890
  }
798
- */
799
891
 
800
892
  this.isRefreshing = true;
801
893
  this.isRefreshPhase = true;
802
894
 
803
- // refresh () {
804
- // ### refresh Fore UI elements
805
- // if (!this.initialRun && this.toRefresh.length !== 0) {
806
- // if (!this.initialRun && this.toRefresh.length !== 0) {
807
- // if (!force && !this.initialRun && this.toRefresh.length !== 0) {
808
- if (force === true || this.initialRun) {
809
- console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
810
- Fore.refreshChildren(this, force);
811
- } else {
812
- // Process all batched no tifications at the end of the refresh phase
813
- console.log('🔄 🎯 ### processing batched notifications');
814
- await this._processBatchedNotifications();
815
- }
816
-
817
- // ### refresh template expressions
818
- if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
819
- this._updateTemplateExpressions();
820
- this._scanForNewTemplateExpressionsNextRefresh = false; // reset
821
- }
822
-
823
- this._processTemplateExpressions();
824
-
825
- this.isRefreshPhase = false;
826
-
827
- // console.log('### <<<<< dispatching refresh-done - end of UI update cycle >>>>>');
828
- // this.dispatchEvent(new CustomEvent('refresh-done'));
829
- this.initialRun = false;
830
- this.style.visibility = 'visible';
831
- console.info(
832
- `%c ✅ refresh-done on #${this.id}`,
833
- 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
834
- this.getModel().modelItems,
835
- );
895
+ try {
896
+ if (force === true || this.initialRun) {
897
+ console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
898
+ await Fore.refreshChildren(this, force);
899
+ } else {
900
+ await this._processBatchedNotifications();
901
+ }
836
902
 
837
- Fore.dispatch(this, 'refresh-done', {});
903
+ if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
904
+ this._updateTemplateExpressions();
905
+ this._scanForNewTemplateExpressionsNextRefresh = false;
906
+ }
838
907
 
839
- const subFores = Array.from(this.querySelectorAll('fx-fore'));
840
- /*
841
- calling the parent to refresh causes errors and inconsistent state. Also it is questionable
842
- if a child should actually interact with its parent in this way.
908
+ this._processTemplateExpressions();
843
909
 
844
- This only affects the refreshing NOT the data mutation itself which is happening as expected.
910
+ this.isRefreshPhase = false;
911
+ this.initialRun = false;
912
+ this.style.visibility = 'visible';
845
913
 
846
- Current solution is that a child that wants the parent to refresh must do so by adding an additional
847
- event handler that dispatches an event upwards and having a handler in the parent to refresh itself.
914
+ console.info(
915
+ `%c refresh-done on #${this.id}`,
916
+ 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
917
+ this.getModel().modelItems,
918
+ );
848
919
 
849
- So refreshed propagate downwards but not upwards which is at least an option to consider.
920
+ Fore.dispatch(this, 'refresh-done', {});
850
921
 
851
- if(this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE){
852
- // await this.parentNode.closest('fx-fore')?.refresh(false);
922
+ const subFores = Array.from(this.querySelectorAll('fx-fore'));
923
+ for (const subFore of subFores) {
924
+ if (subFore.ready) {
925
+ await subFore.refresh(true);
853
926
  }
854
- */
855
- for (const subFore of subFores) {
856
- // subFore.refresh(false, changedPaths);
857
- if (subFore.ready) {
858
- // Do an unconditional hard refresh: there might be changes that are relevant
859
- // todo: investigate impact of observer architecture - do we really want to refresh all subfore elements with a hard refresh?
860
- await subFore.refresh(true);
927
+ }
928
+ } finally {
929
+ this.isRefreshing = false;
930
+
931
+ // If anything requested a refresh while we were refreshing, run exactly one more.
932
+ // This prevents "dropped" refresh requests (your timeout).
933
+ if (this._pendingRefresh) {
934
+ const pendingHard = this._pendingRefresh === true;
935
+ this._pendingRefresh = false;
936
+ // Important: do NOT await in finally without clearing flags first.
937
+ await this.refresh(pendingHard);
861
938
  }
862
939
  }
863
- this.isRefreshing = false;
864
- // Clear the batch
865
- // this.batchedNotifications.clear();
866
940
  }
867
-
868
941
  /**
869
942
  * Add a ModelItem to the batch of notifications to be processed at the end of the refresh phase
870
943
  * @param {ModelItem | import('./ui/UIElement.js').UIElement} item - The ModelItem or UI Element to add to the batch
@@ -881,6 +954,9 @@ export class FxFore extends HTMLElement {
881
954
  */
882
955
  _processBatchedNotifications() {
883
956
  if (this.batchedNotifications.size > 0) {
957
+ console.log(`🔄 🎯 ### processing ${ this.batchedNotifications.size} batched notifications`);
958
+ console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
959
+
884
960
  // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
885
961
 
886
962
  // Process all batched notifications
@@ -921,6 +997,10 @@ export class FxFore extends HTMLElement {
921
997
  }
922
998
  });
923
999
 
1000
+ // Update template expressions after processing batched notifications
1001
+ // This ensures template expressions are re-evaluated when data changes
1002
+ this._processTemplateExpressions();
1003
+
924
1004
  // Clear the batch
925
1005
  this.batchedNotifications.clear();
926
1006
  }
@@ -947,6 +1027,7 @@ export class FxFore extends HTMLElement {
947
1027
 
948
1028
  // console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
949
1029
 
1030
+ if(!tmplExpressions) return;
950
1031
  /*
951
1032
  storing expressions and their nodes for re-evaluation
952
1033
  */
@@ -1010,45 +1091,64 @@ export class FxFore extends HTMLElement {
1010
1091
  * @param {Node} node the node which will get updated with evaluation result
1011
1092
  */
1012
1093
  evaluateTemplateExpression(expr, node) {
1013
- // ### do not evaluate template expressions with nonrelevant sections
1094
+ // ### do not evaluate template expressions within nonrelevant sections
1014
1095
  if (node.nodeType === Node.ATTRIBUTE_NODE && node.ownerElement.closest('[nonrelevant]')) return;
1015
1096
  if (node.nodeType === Node.TEXT_NODE && node.parentNode.closest('[nonrelevant]')) return;
1016
1097
  if (node.nodeType === Node.ELEMENT_NODE && node.closest('[nonrelevant]')) return;
1017
1098
 
1018
- // if(node.closest('[nonrelevant]')) return;
1019
- const replaced = expr.replace(/{[^}]*}/g, match => {
1099
+ // ---- IMPORTANT GUARD ----
1100
+ // Prevent JSON object/array literals in fx-insert@origin from being treated as
1101
+ // template expressions (they contain {...} but are not XPath templates).
1102
+ if (node.nodeType === Node.ATTRIBUTE_NODE) {
1103
+ const el = node.ownerElement;
1104
+ if (el && el.localName === 'fx-insert' && node.name === 'origin') {
1105
+ const v = String(node.value ?? '').trim();
1106
+ const isJsonLiteral =
1107
+ (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
1108
+ if (isJsonLiteral) return;
1109
+ }
1110
+ }
1111
+ // -------------------------
1112
+
1113
+ // The element that "defines" the template expression is the correct basis for:
1114
+ // - namespace resolution (xmlns lookup)
1115
+ // - fx-var scoping (in-scope variables)
1116
+ // - context() in repeats (repeat item detection)
1117
+ const definitionElement =
1118
+ node.nodeType === Node.ATTRIBUTE_NODE
1119
+ ? node.ownerElement
1120
+ : node.nodeType === Node.TEXT_NODE
1121
+ ? (node.parentElement || node.parentNode)
1122
+ : node;
1123
+
1124
+ const formElement =
1125
+ definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
1126
+ ? definitionElement
1127
+ : this;
1128
+
1129
+ const replaced = String(expr ?? '').replace(/{[^}]*}/g, match => {
1020
1130
  if (match === '{}') return match;
1131
+
1021
1132
  const naked = match.substring(1, match.length - 1);
1022
1133
  const inscope = getInScopeContext(node, naked);
1134
+
1023
1135
  if (!inscope) {
1024
- console.warn('no inscope context for expr', naked);
1025
- const errNode =
1026
- node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ATTRIBUTE_NODE
1027
- ? node.parentNode
1028
- : node;
1029
1136
  return match;
1030
1137
  }
1031
- // Templates are special: they use the namespace configuration from the place where they are
1032
- // being defined
1033
- const instanceId = XPathUtil.getInstanceId(naked,node);
1034
-
1035
- // If there is an instance referred
1036
- const inst = instanceId
1037
- ? this.getModel().getInstance(instanceId)
1038
- : this.getModel().getDefaultInstance();
1039
1138
 
1040
1139
  try {
1041
- const result = evaluateXPathToString(naked, inscope, node, null, inst);
1042
- // console.log(`template expression result for ${naked}=${result}`);
1043
- return result;
1140
+ // IMPORTANT:
1141
+ // Do NOT pass `null` as the 4th argument here.
1142
+ // Passing `null` suppresses variable collection, which hides implicit vars
1143
+ // like `$default`.
1144
+ return evaluateXPathToString(naked, inscope, formElement);
1044
1145
  } catch (error) {
1045
1146
  console.warn('ignoring unparseable expr', error);
1046
1147
  return match;
1047
1148
  }
1048
1149
  });
1049
1150
 
1050
- // Update to the new value. Don't do it though if nothing changed to prevent iframes or
1051
- // images from reloading for example
1151
+ // Update to the new value only if it changed (avoid iframe/image reload etc.)
1052
1152
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
1053
1153
  const parent = node.ownerElement;
1054
1154
  if (parent.getAttribute(node.nodeName) !== replaced) {
@@ -1059,9 +1159,7 @@ export class FxFore extends HTMLElement {
1059
1159
  node.textContent = replaced;
1060
1160
  }
1061
1161
  }
1062
- }
1063
-
1064
- // eslint-disable-next-line class-methods-use-this
1162
+ } // eslint-disable-next-line class-methods-use-this
1065
1163
  _getTemplateExpression(node) {
1066
1164
  if (this.ignoredNodes) {
1067
1165
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1340,7 +1438,7 @@ export class FxFore extends HTMLElement {
1340
1438
  const lastMatchingSibling = nodeset.reverse().find(node => parentElement.contains(node));
1341
1439
  if (lastMatchingSibling) {
1342
1440
  return lastMatchingSibling;
1343
- }
1441
+ }
1344
1442
  // Otherwise, just default to appending... If this runs multiple times for multiple nodes
1345
1443
  // it's unexpected to always prepend and get the order of children reversed from the UI.
1346
1444
 
@@ -1365,7 +1463,6 @@ export class FxFore extends HTMLElement {
1365
1463
  // Insert after the previous control
1366
1464
  return referenceNode;
1367
1465
  }
1368
-
1369
1466
  /**
1370
1467
  * @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
1371
1468
  *
@@ -1638,16 +1735,23 @@ export class FxFore extends HTMLElement {
1638
1735
  }
1639
1736
 
1640
1737
  _logError(e) {
1738
+ // Prevent the error event from bubbling up and potentially triggering
1739
+ // parent error handlers that might call refresh() again
1641
1740
  e.stopPropagation();
1741
+ e.stopImmediatePropagation(); // Added to stop other listeners on this element
1642
1742
  e.preventDefault();
1643
1743
 
1644
1744
  console.error('ERROR', e.detail.message);
1645
- console.error(e.detail.origin);
1646
- if (e.detail.expr) {
1647
- console.error('Failing expression', e.detail.expr);
1648
- }
1649
- if (this.strict) {
1650
- this._displayError(e);
1745
+
1746
+ // Guard the display logic: if showing the error causes another error,
1747
+ // we must break the cycle.
1748
+ if (this.strict && !this._isLogging) {
1749
+ this._isLogging = true;
1750
+ try {
1751
+ this._displayError(e);
1752
+ } finally {
1753
+ this._isLogging = false;
1754
+ }
1651
1755
  }
1652
1756
  }
1653
1757