@jinntec/fore 2.9.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
@@ -288,6 +288,7 @@ export class FxFore extends HTMLElement {
288
288
  this.createNodes = this.hasAttribute('create-nodes') ? true : false;
289
289
  this._localNamesWithChanges = new Set();
290
290
  this.setAttribute('role', 'form'); // set aria role
291
+ this._pendingRefresh = false;
291
292
  }
292
293
 
293
294
  /**
@@ -561,6 +562,7 @@ export class FxFore extends HTMLElement {
561
562
  await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
562
563
 
563
564
  await modelElement.modelConstruct();
565
+ console.log("varbindings ",this._instanceVarBindings);
564
566
  this._handleModelConstructDone();
565
567
  }
566
568
 
@@ -674,6 +676,79 @@ export class FxFore extends HTMLElement {
674
676
  }
675
677
  }
676
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
+
677
752
  _injectDevtools() {
678
753
  if (this.ownerDocument.querySelector('fx-devtools')) {
679
754
  // There's already a devtools, so we can ignore this one.
@@ -713,9 +788,11 @@ export class FxFore extends HTMLElement {
713
788
  }
714
789
 
715
790
  markAsClean() {
791
+ console.log('marking as clean', this);
716
792
  this.addEventListener(
717
793
  'value-changed',
718
794
  () => {
795
+ console.log('MARK as modified', this)
719
796
  this.dirtyState = dirtyStates.DIRTY;
720
797
  this.classList.toggle('fx-modified')
721
798
  },
@@ -723,6 +800,7 @@ export class FxFore extends HTMLElement {
723
800
  );
724
801
  this.dirtyState = dirtyStates.CLEAN;
725
802
  this.classList.remove('fx-modified');
803
+ this.querySelectorAll('.visited').forEach(el => el.classList.remove('visited'));
726
804
  }
727
805
 
728
806
  /**
@@ -793,89 +871,73 @@ export class FxFore extends HTMLElement {
793
871
  /**
794
872
  * @param {(boolean|{reason:'index-function'})} [force]fx-fore
795
873
  */
874
+ /**
875
+ * @param {(boolean|{reason:'index-function'})} [force]
876
+ */
877
+ /**
878
+ * @param {(boolean|{reason:'index-function'})} [force]
879
+ */
796
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.
797
883
  if (this.isRefreshing) {
798
- return;
799
- }
884
+ // keep "strongest" request: any true means hard refresh
885
+ this._pendingRefresh = this._pendingRefresh || force === true;
800
886
 
801
- /*
802
- if (force !== true && this._localNamesWithChanges.size > 0) {
803
- force = {
804
- ...(force || { reason: undefined }),
805
- elementLocalnamesWithChanges: Array.from(this._localNamesWithChanges),
806
- };
807
- this._localNamesWithChanges.clear();
887
+ return new Promise(resolve => {
888
+ this.addEventListener('refresh-done', () => resolve(), { once: true });
889
+ });
808
890
  }
809
- */
810
891
 
811
892
  this.isRefreshing = true;
812
893
  this.isRefreshPhase = true;
813
894
 
814
- // refresh () {
815
- // ### refresh Fore UI elements
816
- // if (!this.initialRun && this.toRefresh.length !== 0) {
817
- // if (!this.initialRun && this.toRefresh.length !== 0) {
818
- // if (!force && !this.initialRun && this.toRefresh.length !== 0) {
819
- if (force === true || this.initialRun) {
820
- console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
821
- Fore.refreshChildren(this, force);
822
- } else {
823
- // Process all batched no tifications at the end of the refresh phase
824
- console.log('🔄 🎯 ### processing batched notifications');
825
- await this._processBatchedNotifications();
826
- }
827
-
828
- // ### refresh template expressions
829
- if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
830
- this._updateTemplateExpressions();
831
- this._scanForNewTemplateExpressionsNextRefresh = false; // reset
832
- }
833
-
834
- this._processTemplateExpressions();
835
-
836
- this.isRefreshPhase = false;
837
-
838
- // console.log('### <<<<< dispatching refresh-done - end of UI update cycle >>>>>');
839
- // this.dispatchEvent(new CustomEvent('refresh-done'));
840
- this.initialRun = false;
841
- this.style.visibility = 'visible';
842
- console.info(
843
- `%c ✅ refresh-done on #${this.id}`,
844
- 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
845
- this.getModel().modelItems,
846
- );
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
+ }
847
902
 
848
- Fore.dispatch(this, 'refresh-done', {});
903
+ if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
904
+ this._updateTemplateExpressions();
905
+ this._scanForNewTemplateExpressionsNextRefresh = false;
906
+ }
849
907
 
850
- const subFores = Array.from(this.querySelectorAll('fx-fore'));
851
- /*
852
- calling the parent to refresh causes errors and inconsistent state. Also it is questionable
853
- if a child should actually interact with its parent in this way.
908
+ this._processTemplateExpressions();
854
909
 
855
- 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';
856
913
 
857
- Current solution is that a child that wants the parent to refresh must do so by adding an additional
858
- 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
+ );
859
919
 
860
- So refreshed propagate downwards but not upwards which is at least an option to consider.
920
+ Fore.dispatch(this, 'refresh-done', {});
861
921
 
862
- if(this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE){
863
- // 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);
864
926
  }
865
- */
866
- for (const subFore of subFores) {
867
- // subFore.refresh(false, changedPaths);
868
- if (subFore.ready) {
869
- // Do an unconditional hard refresh: there might be changes that are relevant
870
- // todo: investigate impact of observer architecture - do we really want to refresh all subfore elements with a hard refresh?
871
- 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);
872
938
  }
873
939
  }
874
- this.isRefreshing = false;
875
- // Clear the batch
876
- // this.batchedNotifications.clear();
877
940
  }
878
-
879
941
  /**
880
942
  * Add a ModelItem to the batch of notifications to be processed at the end of the refresh phase
881
943
  * @param {ModelItem | import('./ui/UIElement.js').UIElement} item - The ModelItem or UI Element to add to the batch
@@ -892,6 +954,9 @@ export class FxFore extends HTMLElement {
892
954
  */
893
955
  _processBatchedNotifications() {
894
956
  if (this.batchedNotifications.size > 0) {
957
+ console.log(`🔄 🎯 ### processing ${ this.batchedNotifications.size} batched notifications`);
958
+ console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
959
+
895
960
  // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
896
961
 
897
962
  // Process all batched notifications
@@ -932,6 +997,10 @@ export class FxFore extends HTMLElement {
932
997
  }
933
998
  });
934
999
 
1000
+ // Update template expressions after processing batched notifications
1001
+ // This ensures template expressions are re-evaluated when data changes
1002
+ this._processTemplateExpressions();
1003
+
935
1004
  // Clear the batch
936
1005
  this.batchedNotifications.clear();
937
1006
  }
@@ -958,6 +1027,7 @@ export class FxFore extends HTMLElement {
958
1027
 
959
1028
  // console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
960
1029
 
1030
+ if(!tmplExpressions) return;
961
1031
  /*
962
1032
  storing expressions and their nodes for re-evaluation
963
1033
  */
@@ -1021,45 +1091,64 @@ export class FxFore extends HTMLElement {
1021
1091
  * @param {Node} node the node which will get updated with evaluation result
1022
1092
  */
1023
1093
  evaluateTemplateExpression(expr, node) {
1024
- // ### do not evaluate template expressions with nonrelevant sections
1094
+ // ### do not evaluate template expressions within nonrelevant sections
1025
1095
  if (node.nodeType === Node.ATTRIBUTE_NODE && node.ownerElement.closest('[nonrelevant]')) return;
1026
1096
  if (node.nodeType === Node.TEXT_NODE && node.parentNode.closest('[nonrelevant]')) return;
1027
1097
  if (node.nodeType === Node.ELEMENT_NODE && node.closest('[nonrelevant]')) return;
1028
1098
 
1029
- // if(node.closest('[nonrelevant]')) return;
1030
- 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 => {
1031
1130
  if (match === '{}') return match;
1131
+
1032
1132
  const naked = match.substring(1, match.length - 1);
1033
1133
  const inscope = getInScopeContext(node, naked);
1134
+
1034
1135
  if (!inscope) {
1035
- console.warn('no inscope context for expr', naked);
1036
- const errNode =
1037
- node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ATTRIBUTE_NODE
1038
- ? node.parentNode
1039
- : node;
1040
1136
  return match;
1041
1137
  }
1042
- // Templates are special: they use the namespace configuration from the place where they are
1043
- // being defined
1044
- const instanceId = XPathUtil.getInstanceId(naked,node);
1045
-
1046
- // If there is an instance referred
1047
- const inst = instanceId
1048
- ? this.getModel().getInstance(instanceId)
1049
- : this.getModel().getDefaultInstance();
1050
1138
 
1051
1139
  try {
1052
- const result = evaluateXPathToString(naked, inscope, node, null, inst);
1053
- // console.log(`template expression result for ${naked}=${result}`);
1054
- 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);
1055
1145
  } catch (error) {
1056
1146
  console.warn('ignoring unparseable expr', error);
1057
1147
  return match;
1058
1148
  }
1059
1149
  });
1060
1150
 
1061
- // Update to the new value. Don't do it though if nothing changed to prevent iframes or
1062
- // images from reloading for example
1151
+ // Update to the new value only if it changed (avoid iframe/image reload etc.)
1063
1152
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
1064
1153
  const parent = node.ownerElement;
1065
1154
  if (parent.getAttribute(node.nodeName) !== replaced) {
@@ -1070,9 +1159,7 @@ export class FxFore extends HTMLElement {
1070
1159
  node.textContent = replaced;
1071
1160
  }
1072
1161
  }
1073
- }
1074
-
1075
- // eslint-disable-next-line class-methods-use-this
1162
+ } // eslint-disable-next-line class-methods-use-this
1076
1163
  _getTemplateExpression(node) {
1077
1164
  if (this.ignoredNodes) {
1078
1165
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1351,7 +1438,7 @@ export class FxFore extends HTMLElement {
1351
1438
  const lastMatchingSibling = nodeset.reverse().find(node => parentElement.contains(node));
1352
1439
  if (lastMatchingSibling) {
1353
1440
  return lastMatchingSibling;
1354
- }
1441
+ }
1355
1442
  // Otherwise, just default to appending... If this runs multiple times for multiple nodes
1356
1443
  // it's unexpected to always prepend and get the order of children reversed from the UI.
1357
1444
 
@@ -1376,7 +1463,6 @@ export class FxFore extends HTMLElement {
1376
1463
  // Insert after the previous control
1377
1464
  return referenceNode;
1378
1465
  }
1379
-
1380
1466
  /**
1381
1467
  * @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
1382
1468
  *
@@ -1649,16 +1735,23 @@ export class FxFore extends HTMLElement {
1649
1735
  }
1650
1736
 
1651
1737
  _logError(e) {
1738
+ // Prevent the error event from bubbling up and potentially triggering
1739
+ // parent error handlers that might call refresh() again
1652
1740
  e.stopPropagation();
1741
+ e.stopImmediatePropagation(); // Added to stop other listeners on this element
1653
1742
  e.preventDefault();
1654
1743
 
1655
1744
  console.error('ERROR', e.detail.message);
1656
- console.error(e.detail.origin);
1657
- if (e.detail.expr) {
1658
- console.error('Failing expression', e.detail.expr);
1659
- }
1660
- if (this.strict) {
1661
- 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
+ }
1662
1755
  }
1663
1756
  }
1664
1757