@jinntec/fore 2.9.0 → 3.1.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.
Files changed (42) hide show
  1. package/README.md +0 -2
  2. package/dist/fore-dev.js +9505 -6057
  3. package/dist/fore.js +9218 -5996
  4. package/index.js +1 -0
  5. package/package.json +4 -2
  6. package/src/DependentXPathQueries.js +37 -2
  7. package/src/ForeElementMixin.js +64 -38
  8. package/src/actions/fx-delete.js +424 -73
  9. package/src/actions/fx-insert.js +472 -73
  10. package/src/actions/fx-setattribute.js +5 -5
  11. package/src/actions/fx-setvalue.js +11 -9
  12. package/src/authoring-check.js +231 -0
  13. package/src/fore.js +32 -77
  14. package/src/functions/registerFunction.js +65 -20
  15. package/src/fx-bind.js +128 -73
  16. package/src/fx-fore.js +653 -219
  17. package/src/fx-instance.js +145 -143
  18. package/src/fx-model.js +292 -102
  19. package/src/fx-speech.js +268 -0
  20. package/src/fx-submission.js +246 -135
  21. package/src/fx-var.js +28 -4
  22. package/src/json/JSONDomFacade.js +84 -0
  23. package/src/json/JSONLens.js +67 -0
  24. package/src/json/JSONNode.js +248 -0
  25. package/src/json/lensFromNode.js +5 -0
  26. package/src/modelitem.js +16 -2
  27. package/src/tools/fx-action-log.js +1 -1
  28. package/src/ui/UIElement.js +16 -2
  29. package/src/ui/abstract-control.js +14 -7
  30. package/src/ui/fx-case.js +3 -1
  31. package/src/ui/fx-control.js +4 -2
  32. package/src/ui/fx-group.js +1 -1
  33. package/src/ui/fx-items.js +26 -32
  34. package/src/ui/fx-output.js +8 -38
  35. package/src/ui/fx-repeat-attributes.js +1 -1
  36. package/src/ui/fx-repeat.js +683 -247
  37. package/src/ui/fx-repeatitem.js +16 -1
  38. package/src/ui/repeat-base.js +9 -5
  39. package/src/withDraggability.js +0 -1
  40. package/src/xpath-evaluation.js +1763 -740
  41. package/src/xpath-path.js +274 -24
  42. package/src/xpath-util.js +92 -46
package/src/fx-fore.js CHANGED
@@ -2,7 +2,11 @@ import { Fore } from './fore.js';
2
2
  import './fx-instance.js';
3
3
  import { FxModel } from './fx-model.js';
4
4
  import '@jinntec/jinn-toast';
5
- import { evaluateXPathToNodes, evaluateXPathToString, createNamespaceResolver } from './xpath-evaluation.js';
5
+ import {
6
+ evaluateXPathToNodes,
7
+ evaluateXPathToString,
8
+ createNamespaceResolver,
9
+ } from './xpath-evaluation.js';
6
10
  import getInScopeContext from './getInScopeContext.js';
7
11
  import { XPathUtil } from './xpath-util.js';
8
12
  import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js';
@@ -22,9 +26,7 @@ const dirtyStates = {
22
26
  };
23
27
  async function waitForFunctionLibs(rootEl) {
24
28
  const libs = Array.from(rootEl.querySelectorAll('fx-functionlib'));
25
- await Promise.all(
26
- libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve()))
27
- );
29
+ await Promise.all(libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve())));
28
30
  }
29
31
 
30
32
  /**
@@ -222,8 +224,6 @@ export class FxFore extends HTMLElement {
222
224
  right:0;
223
225
  left:0;
224
226
  height:40px;
225
- border-radius: 5px;
226
-
227
227
  }
228
228
  .popup .close {
229
229
  position: absolute;
@@ -248,6 +248,53 @@ export class FxFore extends HTMLElement {
248
248
  .warning{
249
249
  background:orange;
250
250
  }
251
+ #authoringErrors {
252
+ z-index: 20;
253
+ }
254
+ #authoringErrors .popup {
255
+ width: 70%;
256
+ max-height: 80vh;
257
+ overflow:hidden;
258
+ }
259
+ #authoringErrors h2 {
260
+ background: #c62828;
261
+ color: white;
262
+ padding-left: 12px;
263
+ line-height: 40px;
264
+ font-size: 1rem;
265
+ }
266
+ #authoringErrors table {
267
+ width: 100%;
268
+ border-collapse: collapse;
269
+ font-size: 0.85rem;
270
+ }
271
+ #authoringErrors th {
272
+ text-align: left;
273
+ border-bottom: 2px solid #c62828;
274
+ padding: 4px 8px;
275
+ }
276
+ #authoringErrors td {
277
+ padding: 4px 8px;
278
+ border-bottom: 1px solid #ddd;
279
+ vertical-align: top;
280
+ }
281
+ #authoringErrors td:first-child {
282
+ color: #555;
283
+ font-family: monospace;
284
+ white-space: nowrap;
285
+ }
286
+ #authoringErrors .ae-actions {
287
+ text-align: center;
288
+ margin-top: 12px;
289
+ }
290
+ #authoringErrors .ae-actions button {
291
+ padding: 6px 20px;
292
+ background: #c62828;
293
+ color: white;
294
+ border: none;
295
+ border-radius: 3px;
296
+ cursor: pointer;
297
+ }
251
298
  `;
252
299
 
253
300
  const html = `
@@ -265,6 +312,14 @@ export class FxFore extends HTMLElement {
265
312
  <div id="messageContent"></div>
266
313
  </div>
267
314
  </div>
315
+ <div id="authoringErrors" class="overlay">
316
+ <div class="popup">
317
+ <h2>Authoring Errors</h2>
318
+ <a class="close" href="#" onclick="event.preventDefault();event.target.closest('.overlay').classList.remove('show')">&times;</a>
319
+ <div id="authoringErrorsContent" style="margin-top:48px;"></div>
320
+ <div class="ae-actions"><button onclick="this.closest('.overlay').classList.remove('show')">Dismiss</button></div>
321
+ </div>
322
+ </div>
268
323
  <slot name="event"></slot>
269
324
  `;
270
325
 
@@ -281,13 +336,14 @@ export class FxFore extends HTMLElement {
281
336
  this._scanForNewTemplateExpressionsNextRefresh = false;
282
337
  this.repeatsFromAttributesCreated = false;
283
338
  this.validateOn = this.hasAttribute('validate-on')
284
- ? this.getAttribute('validate-on')
285
- : 'update';
339
+ ? this.getAttribute('validate-on')
340
+ : 'update';
286
341
  // this.mergePartial = this.hasAttribute('merge-partial')? true:false;
287
342
  this.mergePartial = false;
288
343
  this.createNodes = this.hasAttribute('create-nodes') ? true : false;
289
344
  this._localNamesWithChanges = new Set();
290
345
  this.setAttribute('role', 'form'); // set aria role
346
+ this._pendingRefresh = false;
291
347
  }
292
348
 
293
349
  /**
@@ -304,9 +360,9 @@ export class FxFore extends HTMLElement {
304
360
  _parseTargetList(raw) {
305
361
  if (!raw) return [];
306
362
  return raw
307
- .split(/[\s,]+/)
308
- .map(s => s.trim())
309
- .filter(Boolean);
363
+ .split(/[\s,]+/)
364
+ .map(s => s.trim())
365
+ .filter(Boolean);
310
366
  }
311
367
 
312
368
  _findBySelector(sel) {
@@ -322,10 +378,10 @@ export class FxFore extends HTMLElement {
322
378
 
323
379
  _isReadyTarget(el) {
324
380
  return !!(
325
- el &&
326
- (el.ready === true ||
327
- (el.classList && el.classList.contains('fx-ready')) ||
328
- (typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
381
+ el &&
382
+ (el.ready === true ||
383
+ (el.classList && el.classList.contains('fx-ready')) ||
384
+ (typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
329
385
  );
330
386
  }
331
387
 
@@ -342,7 +398,7 @@ export class FxFore extends HTMLElement {
342
398
  if (waitForRaw) {
343
399
  if (!this._warnedWaitForDeprecation) {
344
400
  console.warn(
345
- '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
401
+ '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
346
402
  );
347
403
  this._warnedWaitForDeprecation = true;
348
404
  }
@@ -441,7 +497,7 @@ export class FxFore extends HTMLElement {
441
497
  // Special: closest fx-fore
442
498
  if (targetSpec === 'closest') {
443
499
  const recheckFn =
444
- event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
500
+ event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
445
501
 
446
502
  const matchesFn = ev => {
447
503
  const t = ev.target;
@@ -455,7 +511,7 @@ export class FxFore extends HTMLElement {
455
511
  const selector = targetSpec;
456
512
 
457
513
  const recheckFn =
458
- event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
514
+ event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
459
515
 
460
516
  if (typeof recheckFn === 'function' && recheckFn()) {
461
517
  return Promise.resolve();
@@ -488,7 +544,7 @@ export class FxFore extends HTMLElement {
488
544
  }
489
545
 
490
546
  this._initGatesPromise = Promise.all(gates.map(g => this._waitForInitGate(g))).then(
491
- () => undefined,
547
+ () => undefined,
492
548
  );
493
549
  return this._initGatesPromise;
494
550
  }
@@ -523,7 +579,7 @@ export class FxFore extends HTMLElement {
523
579
  }
524
580
  // Fallback for odd engines/polyfills
525
581
  return (slotEl.assignedNodes({ flatten: true }) || []).filter(
526
- n => n.nodeType === Node.ELEMENT_NODE,
582
+ n => n.nodeType === Node.ELEMENT_NODE,
527
583
  );
528
584
  };
529
585
 
@@ -541,8 +597,8 @@ export class FxFore extends HTMLElement {
541
597
  }
542
598
  if (!modelElement.inited) {
543
599
  console.info(
544
- `%cFore running ... ${this.id ? '#' + this.id : ''}`,
545
- 'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
600
+ `%cFore running ... ${this.id ? '#' + this.id : ''}`,
601
+ 'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
546
602
  );
547
603
 
548
604
  const variables = new Map();
@@ -561,6 +617,7 @@ export class FxFore extends HTMLElement {
561
617
  await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
562
618
 
563
619
  await modelElement.modelConstruct();
620
+ console.log('varbindings ', this._instanceVarBindings);
564
621
  this._handleModelConstructDone();
565
622
  }
566
623
 
@@ -568,7 +625,6 @@ export class FxFore extends HTMLElement {
568
625
  this.inited = true;
569
626
  };
570
627
 
571
-
572
628
  attributeChangedCallback(name, oldValue, newValue) {
573
629
  if (oldValue === newValue) return;
574
630
 
@@ -609,7 +665,7 @@ export class FxFore extends HTMLElement {
609
665
 
610
666
  connectedCallback() {
611
667
  const modelElement = Array.from(this.children).find(
612
- modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
668
+ modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
613
669
  );
614
670
 
615
671
  this.model = modelElement;
@@ -636,8 +692,8 @@ export class FxFore extends HTMLElement {
636
692
  },true);
637
693
  */
638
694
  this.ignoreExpressions = this.hasAttribute('ignore-expressions')
639
- ? this.getAttribute('ignore-expressions')
640
- : null;
695
+ ? this.getAttribute('ignore-expressions')
696
+ : null;
641
697
 
642
698
  this.lazyRefresh = this.hasAttribute('refresh-on-view');
643
699
  if (this.lazyRefresh) {
@@ -674,6 +730,79 @@ export class FxFore extends HTMLElement {
674
730
  }
675
731
  }
676
732
 
733
+ /**
734
+ * Ensure there is an fx-var for each fx-instance in this fx-fore's fx-model scope.
735
+ *
736
+ * - For instances with an @id, create `$id` with value `instance('id')`.
737
+ * - For the first instance WITHOUT an @id, create `$default` with value `instance()`.
738
+ * - IMPORTANT: if an instance has id="default", we STILL bind `$default` to `instance()`
739
+ * (avoids recursion / stack overflow during fx-var refresh in some cycles).
740
+ *
741
+ * Vars are inserted as direct children of `<fx-fore>` immediately before `<fx-model>`.
742
+ * The method is idempotent.
743
+ */
744
+ _ensureInstanceVars() {
745
+ if (this.__instanceVarsEnsured) return;
746
+ this.__instanceVarsEnsured = true;
747
+
748
+ // Resolve this fx-fore's own fx-model (not nested ones)
749
+ const model = this.querySelector(':scope > fx-model');
750
+ if (!model) return;
751
+
752
+ // Collect instances that are direct children of this model (doc order)
753
+ const instances = Array.from(model.querySelectorAll(':scope > fx-instance'));
754
+
755
+ // Collect existing fx-var names at fx-fore scope (author-defined and previously generated)
756
+ const existingVars = new Set(
757
+ Array.from(this.querySelectorAll(':scope > fx-var'))
758
+ .map(v => (v.getAttribute('name') || '').trim())
759
+ .filter(Boolean),
760
+ );
761
+
762
+ let defaultAssigned = false;
763
+
764
+ for (const inst of instances) {
765
+ const rawId = (inst.getAttribute('id') || '').trim();
766
+
767
+ // First id-less instance => $default = instance()
768
+ if (!rawId) {
769
+ if (defaultAssigned) continue;
770
+ defaultAssigned = true;
771
+
772
+ const name = 'default';
773
+ if (existingVars.has(name)) continue;
774
+
775
+ const fxVar = document.createElement('fx-var');
776
+ fxVar.setAttribute('name', name);
777
+ fxVar.setAttribute('value', 'instance()');
778
+ fxVar.setAttribute('data-generated', 'instance-var');
779
+
780
+ this.insertBefore(fxVar, model);
781
+ existingVars.add(name);
782
+ continue;
783
+ }
784
+
785
+ // Normal id-based instance var
786
+ const name = rawId;
787
+ if (existingVars.has(name)) continue;
788
+
789
+ const fxVar = document.createElement('fx-var');
790
+ fxVar.setAttribute('name', name);
791
+
792
+ // IMPORTANT: avoid `instance('default')` recursion in fx-var refresh
793
+ if (name === 'default') {
794
+ fxVar.setAttribute('value', 'instance()');
795
+ } else {
796
+ fxVar.setAttribute('value', `instance('${name}')`);
797
+ }
798
+
799
+ fxVar.setAttribute('data-generated', 'instance-var');
800
+
801
+ this.insertBefore(fxVar, model);
802
+ existingVars.add(name);
803
+ }
804
+ }
805
+
677
806
  _injectDevtools() {
678
807
  if (this.ownerDocument.querySelector('fx-devtools')) {
679
808
  // There's already a devtools, so we can ignore this one.
@@ -713,16 +842,19 @@ export class FxFore extends HTMLElement {
713
842
  }
714
843
 
715
844
  markAsClean() {
845
+ console.log('marking as clean', this);
716
846
  this.addEventListener(
717
- 'value-changed',
718
- () => {
719
- this.dirtyState = dirtyStates.DIRTY;
720
- this.classList.toggle('fx-modified')
721
- },
722
- { once: true },
847
+ 'value-changed',
848
+ () => {
849
+ console.log('MARK as modified', this);
850
+ this.dirtyState = dirtyStates.DIRTY;
851
+ this.classList.toggle('fx-modified');
852
+ },
853
+ { once: true },
723
854
  );
724
855
  this.dirtyState = dirtyStates.CLEAN;
725
856
  this.classList.remove('fx-modified');
857
+ this.querySelectorAll('.visited').forEach(el => el.classList.remove('visited'));
726
858
  }
727
859
 
728
860
  /**
@@ -793,89 +925,76 @@ export class FxFore extends HTMLElement {
793
925
  /**
794
926
  * @param {(boolean|{reason:'index-function'})} [force]fx-fore
795
927
  */
928
+ /**
929
+ * @param {(boolean|{reason:'index-function'})} [force]
930
+ */
931
+ /**
932
+ * @param {(boolean|{reason:'index-function'})} [force]
933
+ */
796
934
  async refresh(force) {
935
+ // If we're already refreshing, do NOT drop the request.
936
+ // Queue a hard refresh and return a promise that resolves when the next refresh finishes.
797
937
  if (this.isRefreshing) {
798
- return;
799
- }
938
+ // keep "strongest" request: any true means hard refresh
939
+ this._pendingRefresh = this._pendingRefresh || force === true;
800
940
 
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();
941
+ return new Promise(resolve => {
942
+ this.addEventListener('refresh-done', () => resolve(), { once: true });
943
+ });
808
944
  }
809
- */
810
945
 
811
946
  this.isRefreshing = true;
812
947
  this.isRefreshPhase = true;
813
948
 
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
- }
949
+ try {
950
+ if (force === true || this.initialRun) {
951
+ performance.mark('force-refresh-start');
952
+ console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
953
+ await Fore.refreshChildren(this, force);
954
+ performance.mark('force-refresh-end');
955
+ performance.measure('force-refresh', 'force-refresh-start', 'force-refresh-end');
956
+ } else {
957
+ await this._processBatchedNotifications();
958
+ }
827
959
 
828
- // ### refresh template expressions
829
- if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
830
- this._updateTemplateExpressions();
831
- this._scanForNewTemplateExpressionsNextRefresh = false; // reset
832
- }
960
+ if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
961
+ this._updateTemplateExpressions();
962
+ this._scanForNewTemplateExpressionsNextRefresh = false;
963
+ }
833
964
 
834
- this._processTemplateExpressions();
965
+ this._processTemplateExpressions();
835
966
 
836
- this.isRefreshPhase = false;
967
+ this.isRefreshPhase = false;
968
+ this.initialRun = false;
969
+ this.style.visibility = 'visible';
837
970
 
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(
971
+ console.info(
843
972
  `%c ✅ refresh-done on #${this.id}`,
844
973
  'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
845
974
  this.getModel().modelItems,
846
- );
847
-
848
- Fore.dispatch(this, 'refresh-done', {});
849
-
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.
854
-
855
- This only affects the refreshing NOT the data mutation itself which is happening as expected.
856
-
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.
975
+ );
859
976
 
860
- So refreshed propagate downwards but not upwards which is at least an option to consider.
977
+ Fore.dispatch(this, 'refresh-done', {});
861
978
 
862
- if(this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE){
863
- // await this.parentNode.closest('fx-fore')?.refresh(false);
979
+ const subFores = Array.from(this.querySelectorAll('fx-fore'));
980
+ for (const subFore of subFores) {
981
+ if (subFore.ready) {
982
+ await subFore.refresh(true);
864
983
  }
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);
984
+ }
985
+ } finally {
986
+ this.isRefreshing = false;
987
+
988
+ // If anything requested a refresh while we were refreshing, run exactly one more.
989
+ // This prevents "dropped" refresh requests (your timeout).
990
+ if (this._pendingRefresh) {
991
+ const pendingHard = this._pendingRefresh === true;
992
+ this._pendingRefresh = false;
993
+ // Important: do NOT await in finally without clearing flags first.
994
+ await this.refresh(pendingHard);
872
995
  }
873
996
  }
874
- this.isRefreshing = false;
875
- // Clear the batch
876
- // this.batchedNotifications.clear();
877
997
  }
878
-
879
998
  /**
880
999
  * Add a ModelItem to the batch of notifications to be processed at the end of the refresh phase
881
1000
  * @param {ModelItem | import('./ui/UIElement.js').UIElement} item - The ModelItem or UI Element to add to the batch
@@ -892,6 +1011,9 @@ export class FxFore extends HTMLElement {
892
1011
  */
893
1012
  _processBatchedNotifications() {
894
1013
  if (this.batchedNotifications.size > 0) {
1014
+ console.log(`🔄 🎯 ### processing ${this.batchedNotifications.size} batched notifications`);
1015
+ console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
1016
+
895
1017
  // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
896
1018
 
897
1019
  // Process all batched notifications
@@ -932,6 +1054,10 @@ export class FxFore extends HTMLElement {
932
1054
  }
933
1055
  });
934
1056
 
1057
+ // Update template expressions after processing batched notifications
1058
+ // This ensures template expressions are re-evaluated when data changes
1059
+ this._processTemplateExpressions();
1060
+
935
1061
  // Clear the batch
936
1062
  this.batchedNotifications.clear();
937
1063
  }
@@ -947,7 +1073,7 @@ export class FxFore extends HTMLElement {
947
1073
  */
948
1074
  _updateTemplateExpressions() {
949
1075
  const search =
950
- "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
1076
+ "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
951
1077
 
952
1078
  const tmplExpressions = evaluateXPathToNodes(search, this, this);
953
1079
  // console.log('template expressions found ', tmplExpressions);
@@ -958,6 +1084,7 @@ export class FxFore extends HTMLElement {
958
1084
 
959
1085
  // console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
960
1086
 
1087
+ if (!tmplExpressions) return;
961
1088
  /*
962
1089
  storing expressions and their nodes for re-evaluation
963
1090
  */
@@ -1021,45 +1148,64 @@ export class FxFore extends HTMLElement {
1021
1148
  * @param {Node} node the node which will get updated with evaluation result
1022
1149
  */
1023
1150
  evaluateTemplateExpression(expr, node) {
1024
- // ### do not evaluate template expressions with nonrelevant sections
1151
+ // ### do not evaluate template expressions within nonrelevant sections
1025
1152
  if (node.nodeType === Node.ATTRIBUTE_NODE && node.ownerElement.closest('[nonrelevant]')) return;
1026
1153
  if (node.nodeType === Node.TEXT_NODE && node.parentNode.closest('[nonrelevant]')) return;
1027
1154
  if (node.nodeType === Node.ELEMENT_NODE && node.closest('[nonrelevant]')) return;
1028
1155
 
1029
- // if(node.closest('[nonrelevant]')) return;
1030
- const replaced = expr.replace(/{[^}]*}/g, match => {
1156
+ // ---- IMPORTANT GUARD ----
1157
+ // Prevent JSON object/array literals in fx-insert@origin from being treated as
1158
+ // template expressions (they contain {...} but are not XPath templates).
1159
+ if (node.nodeType === Node.ATTRIBUTE_NODE) {
1160
+ const el = node.ownerElement;
1161
+ if (el && el.localName === 'fx-insert' && node.name === 'origin') {
1162
+ const v = String(node.value ?? '').trim();
1163
+ const isJsonLiteral =
1164
+ (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
1165
+ if (isJsonLiteral) return;
1166
+ }
1167
+ }
1168
+ // -------------------------
1169
+
1170
+ // The element that "defines" the template expression is the correct basis for:
1171
+ // - namespace resolution (xmlns lookup)
1172
+ // - fx-var scoping (in-scope variables)
1173
+ // - context() in repeats (repeat item detection)
1174
+ const definitionElement =
1175
+ node.nodeType === Node.ATTRIBUTE_NODE
1176
+ ? node.ownerElement
1177
+ : node.nodeType === Node.TEXT_NODE
1178
+ ? node.parentElement || node.parentNode
1179
+ : node;
1180
+
1181
+ const formElement =
1182
+ definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
1183
+ ? definitionElement
1184
+ : this;
1185
+
1186
+ const replaced = String(expr ?? '').replace(/{[^}]*}/g, match => {
1031
1187
  if (match === '{}') return match;
1188
+
1032
1189
  const naked = match.substring(1, match.length - 1);
1033
1190
  const inscope = getInScopeContext(node, naked);
1191
+
1034
1192
  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
1193
  return match;
1041
1194
  }
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
1195
 
1051
1196
  try {
1052
- const result = evaluateXPathToString(naked, inscope, node, null, inst);
1053
- // console.log(`template expression result for ${naked}=${result}`);
1054
- return result;
1197
+ // IMPORTANT:
1198
+ // Do NOT pass `null` as the 4th argument here.
1199
+ // Passing `null` suppresses variable collection, which hides implicit vars
1200
+ // like `$default`.
1201
+ return evaluateXPathToString(naked, inscope, formElement);
1055
1202
  } catch (error) {
1056
1203
  console.warn('ignoring unparseable expr', error);
1057
1204
  return match;
1058
1205
  }
1059
1206
  });
1060
1207
 
1061
- // Update to the new value. Don't do it though if nothing changed to prevent iframes or
1062
- // images from reloading for example
1208
+ // Update to the new value only if it changed (avoid iframe/image reload etc.)
1063
1209
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
1064
1210
  const parent = node.ownerElement;
1065
1211
  if (parent.getAttribute(node.nodeName) !== replaced) {
@@ -1070,9 +1216,7 @@ export class FxFore extends HTMLElement {
1070
1216
  node.textContent = replaced;
1071
1217
  }
1072
1218
  }
1073
- }
1074
-
1075
- // eslint-disable-next-line class-methods-use-this
1219
+ } // eslint-disable-next-line class-methods-use-this
1076
1220
  _getTemplateExpression(node) {
1077
1221
  if (this.ignoredNodes) {
1078
1222
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1121,17 +1265,17 @@ export class FxFore extends HTMLElement {
1121
1265
 
1122
1266
  // ##### lazy creation should NOT take place if there's a parent Fore using shared instances
1123
1267
  const parentFore =
1124
- this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
1125
- ? this.parentNode.closest('fx-fore')
1126
- : null;
1268
+ this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
1269
+ ? this.parentNode.closest('fx-fore')
1270
+ : null;
1127
1271
  if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1128
1272
  console.log('fragment', this.parentNode);
1129
1273
  }
1130
1274
 
1131
1275
  if (parentFore) {
1132
1276
  const shared = parentFore
1133
- .getModel()
1134
- .instances.filter(shared => shared.hasAttribute('shared'));
1277
+ .getModel()
1278
+ .instances.filter(shared => shared.hasAttribute('shared'));
1135
1279
  if (shared.length !== 0) return;
1136
1280
  }
1137
1281
 
@@ -1152,8 +1296,8 @@ export class FxFore extends HTMLElement {
1152
1296
  }
1153
1297
  } catch (e) {
1154
1298
  console.warn(
1155
- 'lazyCreateInstance created an error attempting to create a document',
1156
- e.message,
1299
+ 'lazyCreateInstance created an error attempting to create a document',
1300
+ e.message,
1157
1301
  );
1158
1302
  }
1159
1303
  }
@@ -1210,8 +1354,8 @@ export class FxFore extends HTMLElement {
1210
1354
  */
1211
1355
  async _initUI() {
1212
1356
  console.info(
1213
- `%cinitUI #${this.id}`,
1214
- 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1357
+ `%cinitUI #${this.id}`,
1358
+ 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1215
1359
  );
1216
1360
 
1217
1361
  const parentFore = this.closest('fx-fore');
@@ -1233,7 +1377,12 @@ export class FxFore extends HTMLElement {
1233
1377
 
1234
1378
  // First refresh should be forced
1235
1379
  if (this.createNodes) {
1380
+ performance.mark('initData-start');
1236
1381
  this.initData();
1382
+ performance.mark('initData-end');
1383
+
1384
+ performance.measure('initData', 'initData-start', 'initData-end');
1385
+
1237
1386
  const binds = this.getModel().querySelector('fx-bind');
1238
1387
  if (binds) {
1239
1388
  this.getModel().updateModel();
@@ -1251,13 +1400,12 @@ export class FxFore extends HTMLElement {
1251
1400
  this.initialRun = false;
1252
1401
  // console.log('### >>>>> dispatching ready >>>>>', this);
1253
1402
  console.info(
1254
- `%c ✅ ${this.id ? '#' + this.id : 'Fore'} is ready`,
1255
- 'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1403
+ `%c ✅ ${this.id ? '#' + this.id : 'Fore'} is ready`,
1404
+ 'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1256
1405
  );
1257
1406
 
1258
1407
  // console.log(`### <<<<< ${this.id} ready >>>>>`);
1259
1408
 
1260
- // console.log('### modelItems: ', this.getModel().modelItems);
1261
1409
  Fore.dispatch(this, 'ready', {});
1262
1410
  // console.log('dataChanged', FxModel.dataChanged);
1263
1411
  this.markAsClean();
@@ -1271,6 +1419,50 @@ export class FxFore extends HTMLElement {
1271
1419
  e.stopPropagation();
1272
1420
  e.dataTransfer.dropEffect = 'move';
1273
1421
  });
1422
+
1423
+ // Run authoring checks after ready — they're diagnostic only and must not delay
1424
+ // the ready event or drag-listener registration (both of which tests depend on).
1425
+ try {
1426
+ await this._runAuthoringChecks();
1427
+ } catch (e) {
1428
+ console.warn('[fore] authoring check failed:', e.message);
1429
+ }
1430
+ }
1431
+
1432
+ async _runAuthoringChecks() {
1433
+ if (this.hasAttribute('no-check')) return;
1434
+ if (new URLSearchParams(window.location.search).has('no-check')) return;
1435
+
1436
+ const { checkAuthoring } = await import('./authoring-check.js');
1437
+ const errors = checkAuthoring(this);
1438
+ if (errors.length) {
1439
+ this._showAuthoringErrors(errors);
1440
+ }
1441
+ }
1442
+
1443
+ _showAuthoringErrors(errors) {
1444
+ const overlay = this.shadowRoot.getElementById('authoringErrors');
1445
+ const content = this.shadowRoot.getElementById('authoringErrorsContent');
1446
+ if (!overlay || !content) return;
1447
+
1448
+ const rows = errors
1449
+ .map(({ element, message }) => {
1450
+ const path = element
1451
+ ? element.tagName.toLowerCase() + (element.id ? `#${element.id}` : '')
1452
+ : '?';
1453
+ const safeMsg = message.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1454
+ const safePath = path.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1455
+ return `<tr><td>${safePath}</td><td>${safeMsg}</td></tr>`;
1456
+ })
1457
+ .join('');
1458
+
1459
+ content.innerHTML = `
1460
+ <table>
1461
+ <thead><tr><th>Element</th><th>Problem</th></tr></thead>
1462
+ <tbody>${rows}</tbody>
1463
+ </table>`;
1464
+
1465
+ overlay.classList.add('show');
1274
1466
  }
1275
1467
 
1276
1468
  /**
@@ -1376,28 +1568,189 @@ export class FxFore extends HTMLElement {
1376
1568
  // Insert after the previous control
1377
1569
  return referenceNode;
1378
1570
  }
1379
-
1380
1571
  /**
1381
1572
  * @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
1382
- *
1383
1573
  */
1384
1574
  initData(root = this) {
1385
- // const created = new Promise(resolve => {
1386
- // console.log('INIT');
1387
- // const boundControls = Array.from(root.querySelectorAll('[ref]:not(fx-model *),fx-repeatitem'));
1575
+ /**
1576
+ * @param {*} value
1577
+ * @returns {boolean}
1578
+ */
1579
+ const isObjectLike = value =>
1580
+ value !== null && (typeof value === 'object' || typeof value === 'function');
1581
+
1582
+ /**
1583
+ * @param {*} nodeset
1584
+ * @returns {boolean}
1585
+ */
1586
+ const hasResolvedNodeset = nodeset => {
1587
+ if (nodeset == null) return false;
1588
+
1589
+ // Atomic values like strings/numbers/booleans are valid resolved XPath results
1590
+ if (!isObjectLike(nodeset)) return true;
1591
+
1592
+ if (Array.isArray(nodeset)) return nodeset.length > 0;
1593
+
1594
+ if (typeof nodeset.length === 'number' && !('nodeType' in nodeset)) {
1595
+ return nodeset.length > 0;
1596
+ }
1597
+
1598
+ if (typeof nodeset[Symbol.iterator] === 'function' && !('nodeType' in nodeset)) {
1599
+ for (const item of nodeset) {
1600
+ return item !== undefined;
1601
+ }
1602
+ return false;
1603
+ }
1604
+
1605
+ return !!nodeset.nodeType;
1606
+ };
1607
+
1608
+ /**
1609
+ * Only try create-nodes for path-like refs, not general expressions like sequences.
1610
+ * @param {string} ref
1611
+ * @returns {boolean}
1612
+ */
1613
+ const isCreateNodesCandidate = ref => {
1614
+ const expr = String(ref || '').trim();
1615
+ if (!expr || expr === '.') return false;
1616
+ if (expr.startsWith('"') || expr.startsWith("'")) return false;
1617
+
1618
+ // Ignore only simple literal sequences like ('a', 'b', 'c') or (1, 2, 3).
1619
+ // Keep fx-repeat refs that are real path expressions or more complex XPath.
1620
+ const simpleSequencePattern =
1621
+ /^\(\s*(?:(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.)(?:\s*,\s*(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.))*)?\s*\)$/;
1622
+ if (simpleSequencePattern.test(expr)) return false;
1623
+
1624
+ return true;
1625
+ };
1626
+
1627
+ /**
1628
+ * Detect whether a ref ends in an attribute step.
1629
+ * @param {string} ref
1630
+ * @returns {boolean}
1631
+ */
1632
+ const isAttributeRef = ref => /(^|\/)\s*@/.test(String(ref || '').trim());
1633
+
1634
+ /**
1635
+ * Normalize a possibly sequence-like nodeset/context to a single DOM node.
1636
+ * @param {*} candidate
1637
+ * @returns {*}
1638
+ */
1639
+ const firstNode = candidate => {
1640
+ if (!candidate) return null;
1641
+ if (!isObjectLike(candidate)) return null;
1642
+ if (candidate.nodeType) return candidate;
1643
+
1644
+ if (Array.isArray(candidate)) {
1645
+ return candidate.find(item => item && isObjectLike(item) && item.nodeType) || null;
1646
+ }
1647
+
1648
+ if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
1649
+ for (let i = 0; i < candidate.length; i += 1) {
1650
+ const item = candidate.item(i);
1651
+ if (item && isObjectLike(item) && item.nodeType) return item;
1652
+ }
1653
+ }
1654
+
1655
+ return null;
1656
+ };
1657
+
1658
+ /**
1659
+ * Detect XPath results that are sequences of atomic values rather than DOM nodes.
1660
+ * These are valid resolved results for repeats, but they must never trigger create-nodes.
1661
+ * @param {*} candidate
1662
+ * @returns {boolean}
1663
+ */
1664
+ const isAtomicSequence = candidate => {
1665
+ if (!candidate) return false;
1666
+
1667
+ // A single primitive value is also atomic from our perspective
1668
+ if (!isObjectLike(candidate)) return true;
1669
+
1670
+ if (candidate.nodeType) return false;
1671
+
1672
+ if (Array.isArray(candidate)) {
1673
+ return (
1674
+ candidate.length > 0 &&
1675
+ !candidate.some(item => item && isObjectLike(item) && item.nodeType)
1676
+ );
1677
+ }
1678
+
1679
+ if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
1680
+ return false;
1681
+ }
1682
+
1683
+ if (typeof candidate[Symbol.iterator] === 'function') {
1684
+ for (const item of candidate) {
1685
+ return !(item && isObjectLike(item) && item.nodeType);
1686
+ }
1687
+ }
1688
+
1689
+ return false;
1690
+ };
1691
+
1692
+ /**
1693
+ * Check whether a bound element is resolved after evalInContext.
1694
+ * Attribute refs often expose an empty string as nodeset, so use the model item node in that case.
1695
+ * @param {import('./ForeElementMixin.js').default} bound
1696
+ * @returns {boolean}
1697
+ */
1698
+ const isResolvedBound = bound => {
1699
+ if (hasResolvedNodeset(bound.nodeset)) return true;
1700
+ if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) return true;
1701
+ if (isAttributeRef(bound.ref)) {
1702
+ const modelItem = typeof bound.getModelItem === 'function' ? bound.getModelItem() : null;
1703
+ return !!modelItem?.node;
1704
+ }
1705
+ return false;
1706
+ };
1707
+
1708
+ /**
1709
+ * Determine the best context node for lazy node creation.
1710
+ * Prefer the bound element's in-scope context, but fall back to a structural parent.
1711
+ * @param {import('./ForeElementMixin.js').default} bound
1712
+ * @param {*} fallback
1713
+ * @returns {*}
1714
+ */
1715
+ const getCreationContext = (bound, fallback) => {
1716
+ const direct =
1717
+ typeof bound.getInScopeContext === 'function' ? firstNode(bound.getInScopeContext()) : null;
1718
+ if (direct) return direct;
1719
+
1720
+ const dotCtx = firstNode(getInScopeContext(bound, '.'));
1721
+ if (dotCtx) return dotCtx;
1722
+
1723
+ const refCtx = firstNode(getInScopeContext(bound, bound.ref));
1724
+ if (refCtx) return refCtx;
1725
+
1726
+ return firstNode(fallback);
1727
+ };
1388
1728
 
1389
1729
  /**
1390
1730
  * @type {import('./ForeElementMixin.js').default[]}
1391
1731
  */
1392
1732
  const boundControls = Array.from(
1393
- root.querySelectorAll(
1394
- 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]',
1395
- ),
1396
- );
1397
- if (root.matches && root.matches('fx-repeatitem')) {
1733
+ root.querySelectorAll(
1734
+ 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]',
1735
+ ),
1736
+ ).filter(boundEl => {
1737
+ if (boundEl.nodeName !== 'FX-REPEAT') return true;
1738
+
1739
+ const repeatRef = String(boundEl.getAttribute('ref') || '').trim();
1740
+
1741
+ // Any repeat whose ref is a pure parenthesized expression is not a create-nodes candidate.
1742
+ // Example: ('a', 'b', 'c')
1743
+ if (repeatRef.startsWith('(')) return false;
1744
+
1745
+ return isCreateNodesCandidate(repeatRef);
1746
+ });
1747
+
1748
+ if (root.matches && root.matches('fx-repeatitem') && firstNode(root.nodeset)) {
1398
1749
  boundControls.unshift(root);
1399
1750
  }
1751
+
1400
1752
  console.log('_initData', boundControls);
1753
+
1401
1754
  for (let i = 0; i < boundControls.length; i++) {
1402
1755
  const bound = boundControls[i];
1403
1756
 
@@ -1408,11 +1761,21 @@ export class FxFore extends HTMLElement {
1408
1761
  // Repeat items are dumb. They do not respond to evalInContext
1409
1762
  bound.evalInContext();
1410
1763
  }
1411
- if (bound.nodeset !== null && !(Array.isArray(bound.nodeset) && bound.nodeset.length === 0)) {
1412
- // console.log('Node exists', bound.nodeset);
1764
+
1765
+ if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) {
1766
+ continue;
1767
+ }
1768
+ if (isResolvedBound(bound)) {
1769
+ continue;
1770
+ }
1771
+ if (!isCreateNodesCandidate(bound.ref)) {
1772
+ continue;
1773
+ }
1774
+
1775
+ // Ignore bound elements in a different form. They will be taken care of in the other form.
1776
+ if (bound.closest('fx-fore') !== this) {
1413
1777
  continue;
1414
1778
  }
1415
- // console.log('Node does not exists', bound.ref);
1416
1779
 
1417
1780
  // We need to create that node!
1418
1781
  const previousControl = boundControls[i - 1];
@@ -1420,26 +1783,16 @@ export class FxFore extends HTMLElement {
1420
1783
  // Previous control can either be an ancestor of us, or a previous node, which can be a sibling, or a child of a sibling.
1421
1784
  // First: parent
1422
1785
  if (previousControl && previousControl.contains(bound)) {
1423
- // Parent is here.
1424
- // console.log('insert into', bound, previousControl);
1425
- // console.log('insert into nodeset', bound.nodeset);
1426
1786
  /**
1427
1787
  * @type {ParentNode}
1428
1788
  */
1429
- const parentNodeset = previousControl.nodeset;
1430
- // console.log('parentNodeset', parentNodeset);
1431
-
1432
- // const parentModelItemNode = parentModelItem.node;
1789
+ const parentNodeset =
1790
+ firstNode(previousControl.nodeset) || firstNode(root.getModel().getDefaultContext());
1791
+ const creationContext = getCreationContext(bound, parentNodeset);
1433
1792
  const ref = bound.ref;
1434
- // const newElement = parentModelItemNode.ownerDocument.createElement(ref);
1435
- // if (parentNodeset.querySelector(`[ref="${ref}"]`)) {
1436
- // console.log(`Node with ref "${ref}" already exists.`);
1437
- // continue;
1438
- // }
1439
1793
 
1440
- const newNode = this._createNodes(ref, parentNodeset);
1441
- if (!newNode) {
1442
- // We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
1794
+ const newNode = this._createNodes(ref, creationContext || parentNodeset);
1795
+ if (!newNode || !parentNodeset) {
1443
1796
  continue;
1444
1797
  }
1445
1798
  if (newNode.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1457,17 +1810,11 @@ export class FxFore extends HTMLElement {
1457
1810
  // Do not try to get a bind for a nodeSET of a repeat. there are multiple.
1458
1811
  bound.getModelItem().bind?.evalInContext();
1459
1812
  }
1460
-
1461
- // console.log('CREATED child', newElement);
1462
- // console.log('new control evaluated to ', control.nodeset);
1463
- // Done!
1464
1813
  continue;
1465
1814
  }
1466
- // console.log('previousControl', previousControl);
1467
- // console.log('control', control);
1815
+
1468
1816
  // Is previousControl a sibling or a descendant of a logical sibling? Keep looking backwards until we share parents!
1469
- let ourParent = XPathUtil.getParentBindingElement(bound);
1470
- // console.log('ourParent', ourParent);
1817
+ const ourParent = XPathUtil.getParentBindingElement(bound);
1471
1818
  let siblingControl = null;
1472
1819
 
1473
1820
  for (let j = i - 1; j > 0; --j) {
@@ -1480,11 +1827,7 @@ export class FxFore extends HTMLElement {
1480
1827
  break;
1481
1828
  }
1482
1829
  }
1483
- if (!siblingControl) {
1484
- // console.log('No sibling found for', bound);
1485
- }
1486
- // console.log('sibling', siblingControl);
1487
- // todo: review: should this not just be inscopeContext?
1830
+
1488
1831
  let parentNodeset;
1489
1832
  if (!ourParent || !ourParent.nodeset) {
1490
1833
  /*
@@ -1493,13 +1836,13 @@ export class FxFore extends HTMLElement {
1493
1836
  */
1494
1837
  parentNodeset = root.getModel().getDefaultContext();
1495
1838
  } else {
1496
- parentNodeset = ourParent.nodeset;
1839
+ parentNodeset = firstNode(ourParent.nodeset) || root.getModel().getDefaultContext();
1497
1840
  }
1498
1841
  const ref = bound.ref;
1842
+ const creationContext = getCreationContext(bound, parentNodeset);
1499
1843
 
1500
- const newNode = this._createNodes(ref, parentNodeset);
1844
+ const newNode = this._createNodes(ref, creationContext || parentNodeset);
1501
1845
  if (!newNode) {
1502
- // We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
1503
1846
  continue;
1504
1847
  }
1505
1848
 
@@ -1507,13 +1850,12 @@ export class FxFore extends HTMLElement {
1507
1850
  parentNodeset.setAttributeNode(newNode);
1508
1851
  } else {
1509
1852
  let referenceNode = this._findReferenceNodeForNewElement(
1510
- newNode,
1511
- parentNodeset,
1512
- siblingControl,
1853
+ newNode,
1854
+ parentNodeset,
1855
+ siblingControl,
1513
1856
  );
1514
1857
 
1515
1858
  if (referenceNode) {
1516
- // console.log('insert after', referenceNode,newNode);
1517
1859
  if (referenceNode.nodeType === Node.DOCUMENT_NODE) {
1518
1860
  referenceNode.firstElementChild.append(newNode);
1519
1861
  } else {
@@ -1524,50 +1866,135 @@ export class FxFore extends HTMLElement {
1524
1866
  }
1525
1867
  }
1526
1868
 
1527
- /*
1528
- console.log('control inscope', control.getInScopeContext());
1529
- console.log('control ref', control.ref);
1530
- console.log('control new element parent', newElement.parentNode.nodeName);
1531
- */
1532
-
1533
1869
  bound.evalInContext();
1534
1870
  bound.getModelItem().bind?.evalInContext();
1535
1871
 
1536
- if (!bound.nodeset) {
1537
- throw new Error('Creating annode failed');
1872
+ if (!isResolvedBound(bound)) {
1873
+ console.warn('create-nodes: could not resolve bound after node creation, skipping', bound);
1874
+ continue;
1538
1875
  }
1539
- // console.log('new control evaluated to ', control.nodeset);
1540
- // console.log('CREATED sibling', newElement);
1541
1876
  }
1542
- // console.log('DATA', this.getModel().getDefaultContext());
1543
1877
  }
1544
-
1545
1878
  _createNodes(ref, referenceNode) {
1546
- // console.log('creating', ref)
1547
- // console.log('ownerDoc', referenceNode.ownerDocument);
1548
- /*
1549
- const existingNode = evaluateXPathToFirstNode(ref, referenceNode, this);
1550
- if(existingNode){
1551
- console.log(`Node already exists for ref: ${ref}`);
1552
- return existingNode;
1553
- }
1554
- console.log(`creating new node for ref: ${ref}`);
1555
- */
1556
- if (/instance\([^\)]*\)/.test(ref)) {
1557
- // This is an absolute path for some instance. Not supporteed for now
1879
+ if (!ref || !referenceNode) return null;
1880
+
1881
+ const xpath = String(ref).trim();
1882
+ if (!xpath || xpath === '.') return null;
1883
+
1884
+ if (/^instance\([^\)]*\)/.test(xpath)) {
1885
+ // This is an absolute path for some instance. Not supported for create-nodes here.
1558
1886
  return null;
1559
1887
  }
1560
- let newElement;
1561
- if (ref.includes('/')) {
1562
- // multi-step ref expressions
1563
- const namespaceResolver = createNamespaceResolver(ref, this);
1564
- newElement = XPathUtil.createNodesFromXPath(ref, referenceNode.ownerDocument, this, namespaceResolver);
1565
- // console.log('new subtree', newElement);
1566
- return newElement;
1567
- } else {
1568
- const namespaceResolver = createNamespaceResolver(ref, this);
1569
- return XPathUtil.createNodesFromXPath(ref, referenceNode.ownerDocument, this, namespaceResolver);
1888
+
1889
+ const ownerDoc =
1890
+ referenceNode.nodeType === Node.DOCUMENT_NODE ? referenceNode : referenceNode.ownerDocument;
1891
+
1892
+ const baseElement =
1893
+ referenceNode.nodeType === Node.DOCUMENT_NODE
1894
+ ? referenceNode.documentElement
1895
+ : referenceNode.nodeType === Node.ATTRIBUTE_NODE
1896
+ ? referenceNode.ownerElement
1897
+ : referenceNode;
1898
+
1899
+ if (!ownerDoc) return null;
1900
+
1901
+ const baseNamespace = baseElement?.namespaceURI || null;
1902
+ const namespaceResolver = createNamespaceResolver(xpath, this);
1903
+
1904
+ const parseName = token => {
1905
+ const raw = token.trim();
1906
+
1907
+ if (raw.startsWith('@')) {
1908
+ const attrToken = raw.slice(1);
1909
+ if (attrToken.startsWith('*:')) {
1910
+ return { isAttribute: true, namespaceURI: null, localName: attrToken.substring(2) };
1911
+ }
1912
+ if (attrToken.includes(':')) {
1913
+ const [prefix, localName] = attrToken.split(':');
1914
+ return {
1915
+ isAttribute: true,
1916
+ namespaceURI: prefix === '*' ? null : namespaceResolver(prefix) || null,
1917
+ localName,
1918
+ };
1919
+ }
1920
+ return { isAttribute: true, namespaceURI: null, localName: attrToken };
1921
+ }
1922
+
1923
+ if (raw.startsWith('*:')) {
1924
+ return { isAttribute: false, namespaceURI: baseNamespace, localName: raw.substring(2) };
1925
+ }
1926
+ if (raw.includes(':')) {
1927
+ const [prefix, localName] = raw.split(':');
1928
+ return {
1929
+ isAttribute: false,
1930
+ namespaceURI: prefix === '*' ? baseNamespace : namespaceResolver(prefix) || baseNamespace,
1931
+ localName,
1932
+ };
1933
+ }
1934
+ return { isAttribute: false, namespaceURI: baseNamespace, localName: raw };
1935
+ };
1936
+
1937
+ const parseStep = step => {
1938
+ const trimmed = step.trim();
1939
+ const nameMatch = trimmed.match(/^([^\[]+)/);
1940
+ const token = nameMatch ? nameMatch[1].trim() : trimmed;
1941
+ const predicates = [];
1942
+
1943
+ const predicateRegex = /\[\s*@([^\]\s=]+)\s*=\s*(['"])(.*?)\2\s*\]/g;
1944
+ let match;
1945
+ while ((match = predicateRegex.exec(trimmed)) !== null) {
1946
+ predicates.push({ name: match[1], value: match[3] });
1947
+ }
1948
+ return { token, predicates };
1949
+ };
1950
+
1951
+ const steps = xpath
1952
+ .split('/')
1953
+ .map(step => step.trim())
1954
+ .filter(step => step && step !== '.');
1955
+
1956
+ if (!steps.length) return null;
1957
+
1958
+ let subtreeRoot = null;
1959
+ let current = null;
1960
+
1961
+ for (const rawStep of steps) {
1962
+ const { token, predicates } = parseStep(rawStep);
1963
+ if (!token || token === '.') {
1964
+ continue;
1965
+ }
1966
+
1967
+ const parsed = parseName(token);
1968
+
1969
+ if (parsed.isAttribute) {
1970
+ if (!current) {
1971
+ const attr = ownerDoc.createAttribute(parsed.localName);
1972
+ return attr;
1973
+ }
1974
+ current.setAttribute(parsed.localName, '');
1975
+ continue;
1976
+ }
1977
+
1978
+ const element = parsed.namespaceURI
1979
+ ? ownerDoc.createElementNS(parsed.namespaceURI, parsed.localName)
1980
+ : ownerDoc.createElement(parsed.localName);
1981
+
1982
+ for (const predicate of predicates) {
1983
+ const attrName = predicate.name.includes(':')
1984
+ ? predicate.name.split(':')[1]
1985
+ : predicate.name;
1986
+ element.setAttribute(attrName, predicate.value);
1987
+ }
1988
+
1989
+ if (!subtreeRoot) {
1990
+ subtreeRoot = element;
1991
+ } else {
1992
+ current.appendChild(element);
1993
+ }
1994
+ current = element;
1570
1995
  }
1996
+
1997
+ return subtreeRoot;
1571
1998
  }
1572
1999
 
1573
2000
  _handleDragStart(event) {
@@ -1649,16 +2076,23 @@ export class FxFore extends HTMLElement {
1649
2076
  }
1650
2077
 
1651
2078
  _logError(e) {
2079
+ // Prevent the error event from bubbling up and potentially triggering
2080
+ // parent error handlers that might call refresh() again
1652
2081
  e.stopPropagation();
2082
+ e.stopImmediatePropagation(); // Added to stop other listeners on this element
1653
2083
  e.preventDefault();
1654
2084
 
1655
2085
  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);
2086
+
2087
+ // Guard the display logic: if showing the error causes another error,
2088
+ // we must break the cycle.
2089
+ if (this.strict && !this._isLogging) {
2090
+ this._isLogging = true;
2091
+ try {
2092
+ this._displayError(e);
2093
+ } finally {
2094
+ this._isLogging = false;
2095
+ }
1662
2096
  }
1663
2097
  }
1664
2098