@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
@@ -120,56 +120,33 @@ export class FxSubmission extends ForeElementMixin {
120
120
  async _serializeAndSend() {
121
121
  const url = this._getProperty('url');
122
122
  const resolvedUrl = this.evaluateAttributeTemplateExpression(url, this);
123
- // console.log('resolvedUrl', resolvedUrl);
123
+
124
124
  const instance = this.getInstance();
125
125
  if (!instance) {
126
- Fore.dispatch(this, 'warn', { message: `instance not found ${instance.getAttribute('id')}` });
126
+ Fore.dispatch(this, 'warn', { message: `instance not found ${instance?.getAttribute?.('id')}` });
127
127
  }
128
128
  const instType = instance.getAttribute('type');
129
- // console.log('instance type', instance.type);
130
129
 
131
130
  let serialized;
132
131
  if (this.serialization === 'none') {
133
132
  serialized = undefined;
134
133
  } else {
135
- // const relevant = this.selectRelevant(instance.type);
136
134
  const relevant = Relevance.selectRelevant(this, instType);
137
- serialized = this._serialize(instType, relevant);
135
+ serialized = this._serialize(instance, relevant);
138
136
  }
139
137
 
140
- // let serialized = serializer.serializeToString(relevant);
141
138
  if (this.method.toLowerCase() === 'get') {
142
- /*
143
- todo: serialize the bound instance element names as get parameters and using their text values
144
- as param values. leave out empty params and create querystring from the result.Elements may
145
- have exactly level deep or are otherwise ignored.
146
- <data>
147
- <id>1234</id>
148
- <name>john</name>
149
- <zip></zip>
150
- <!-- ignored as no direct text value -->
151
- <phone>
152
- <mobile></mobile>
153
- <phone>
154
- </data>
155
- results in: ?id=1234&name=john to be appended to this.url on fetch
156
-
157
- */
158
-
159
139
  serialized = undefined;
160
140
  }
161
- // console.log('data being send', serialized);
162
- // console.log('submitting data',serialized);
163
141
 
164
- // if (resolvedUrl === '#echo') {
142
+ // --- echo / localStore shortcuts ---
165
143
  if (resolvedUrl.startsWith('#echo')) {
166
144
  if (this.replace === 'download') {
167
- this._handleResponse(serialized, resolvedUrl, 'application/xml');
145
+ await this._handleResponse(serialized, resolvedUrl, 'application/xml');
168
146
  } else {
169
147
  const data = this._parse(serialized, instance);
170
- this._handleResponse(data, resolvedUrl, 'application/xml');
148
+ await this._handleResponse(data, resolvedUrl, 'application/xml');
171
149
  }
172
- // this.dispatch('submit-done', {});
173
150
  console.log('### <<<<< submit-done >>>>>');
174
151
  Fore.dispatch(this, 'submit-done', {});
175
152
  this.parameters.clear();
@@ -178,11 +155,10 @@ export class FxSubmission extends ForeElementMixin {
178
155
 
179
156
  if (resolvedUrl.startsWith('localStore:')) {
180
157
  if (this.method === 'get' || this.method === 'consume') {
181
- // let data = this._parse(serialized, instance);
182
158
  this.replace = 'instance';
183
159
  const key = resolvedUrl.substring(resolvedUrl.indexOf(':') + 1);
184
- const serialized = localStorage.getItem(key);
185
- if (!serialized) {
160
+ const stored = localStorage.getItem(key);
161
+ if (!stored) {
186
162
  Fore.dispatch(this, 'submit-error', {
187
163
  status: 400,
188
164
  message: `Error reading key ${key} from localstorage`,
@@ -190,28 +166,29 @@ export class FxSubmission extends ForeElementMixin {
190
166
  this.parameters.clear();
191
167
  return;
192
168
  }
193
- const data = this._parse(serialized, instance);
194
- this._handleResponse(data);
169
+ const data = this._parse(stored, instance);
170
+ await this._handleResponse(data);
195
171
  if (this.method === 'consume') {
196
172
  localStorage.removeItem(key);
197
173
  }
198
174
  console.log('### <<<<< submit-done >>>>>');
199
175
  Fore.dispatch(this, 'submit-done', {});
200
176
  }
177
+
201
178
  if (this.method === 'post') {
202
- // let data = this._parse(serialized, instance);
203
179
  const key = resolvedUrl.substring(resolvedUrl.indexOf(':') + 1);
204
180
  localStorage.setItem(key, serialized);
205
- this._handleResponse(instance.instanceData);
181
+ await this._handleResponse(instance.instanceData);
206
182
  console.log('### <<<<< submit-done >>>>>');
207
183
  Fore.dispatch(this, 'submit-done', {});
208
184
  }
185
+
209
186
  if (this.method === 'delete') {
210
187
  const key = resolvedUrl.substring(resolvedUrl.indexOf(':') + 1);
211
188
  localStorage.removeItem(key);
212
189
  const newInst = new DOMParser().parseFromString('<data></data>', 'application/xml');
213
190
  this.replace = 'instance';
214
- this._handleResponse(newInst);
191
+ await this._handleResponse(newInst);
215
192
  console.log('### <<<<< submit-done >>>>>');
216
193
  Fore.dispatch(this, 'submit-done', {});
217
194
  }
@@ -219,14 +196,14 @@ export class FxSubmission extends ForeElementMixin {
219
196
  return;
220
197
  }
221
198
 
222
- // ### setting headers
199
+ // --- network fetch ---
223
200
  const headers = this._getHeaders();
224
201
 
225
202
  if (!this.methods.includes(this.method.toLowerCase())) {
226
- // this.dispatch('error', { message: `Unknown method ${this.method}` });
227
203
  Fore.dispatch(this, 'error', { message: `Unknown method ${this.method}` });
228
204
  return;
229
205
  }
206
+
230
207
  try {
231
208
  const response = await fetch(resolvedUrl, {
232
209
  method: this.method,
@@ -237,10 +214,9 @@ export class FxSubmission extends ForeElementMixin {
237
214
  });
238
215
 
239
216
  if (!response.ok || response.status > 400) {
240
- // this.dispatch('submit-error', { message: `Error while submitting ${this.id}` });
241
217
  console.info(
242
- `%csubmit-error #${this.id}`,
243
- 'background:red; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
218
+ `%csubmit-error #${this.id}`,
219
+ 'background:red; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
244
220
  );
245
221
 
246
222
  Fore.dispatch(this, 'submit-error', {
@@ -251,30 +227,23 @@ export class FxSubmission extends ForeElementMixin {
251
227
  }
252
228
 
253
229
  const contentType = response.headers.get('content-type').split(';')[0].trim().toLowerCase();
230
+
254
231
  if (contentType.endsWith('/xml') || contentType.endsWith('+xml')) {
255
232
  const text = await response.text();
256
233
  const xml = new DOMParser().parseFromString(text, 'application/xml');
257
- this._handleResponse(xml, resolvedUrl, contentType);
234
+ await this._handleResponse(xml, resolvedUrl, contentType);
258
235
  } else if (contentType.startsWith('text/')) {
259
236
  const text = await response.text();
260
- this._handleResponse(text, resolvedUrl, contentType);
237
+ await this._handleResponse(text, resolvedUrl, contentType);
261
238
  } else if (contentType.endsWith('/json') || contentType.endsWith('+json')) {
262
239
  const json = await response.json();
263
- this._handleResponse(json, resolvedUrl, contentType);
240
+ await this._handleResponse(json, resolvedUrl, contentType);
264
241
  } else {
265
242
  const blob = await response.blob();
266
- this._handleResponse(blob, resolvedUrl, contentType);
243
+ await this._handleResponse(blob, resolvedUrl, contentType);
267
244
  }
268
245
 
269
- // this.dispatch('submit-done', {});
270
- // console.log(`### <<<<< ${this.id} submit-done >>>>>`);
271
246
  Fore.dispatch(this, 'submit-done', {});
272
- /*
273
- console.info(
274
- `%csubmit-done #${this.id}`,
275
- 'background:green; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
276
- );
277
- */
278
247
  } catch (error) {
279
248
  Fore.dispatch(this, 'submit-error', { status: 500, error: error.message });
280
249
  } finally {
@@ -300,28 +269,165 @@ export class FxSubmission extends ForeElementMixin {
300
269
  return data;
301
270
  }
302
271
 
303
- _serialize(instanceType, relevantNodes) {
304
- if (this.serialization === 'application/x-www-form-urlencoded') {
305
- // this.method = 'post';
306
- const params = new URLSearchParams();
307
- // console.log('nodes to serialize', relevantNodes);
308
- Array.from(relevantNodes.children).forEach(child => {
309
- params.append(child.nodeName, child.textContent);
310
- });
311
- return params;
272
+ /**
273
+ * Serialize the submission payload depending on instance type.
274
+ *
275
+ * - XML instances => XML serialization (existing behavior)
276
+ * - JSON instances => JSON serialization from plain JS (NOT from JSONNode lens objects)
277
+ *
278
+ * @param {import('./fx-instance.js').FxInstance | null} instanceEl
279
+ * @param {any} data
280
+ * @returns {string}
281
+ */
282
+ _serialize(instanceEl, data) {
283
+ // If the caller passed an explicit "data", prefer it; otherwise serialize the instance.
284
+ let payload = data;
285
+
286
+ // Resolve instance if not provided explicitly
287
+ if (!instanceEl) {
288
+ const model = this.getOwnerForm()?.getModel?.();
289
+ const instanceId = this.getAttribute('instance') || 'default';
290
+ instanceEl = model?.getInstance?.(instanceId) || null;
291
+ }
292
+
293
+ // If no payload was passed, derive it from instance default context/nodeset
294
+ if (payload == null && instanceEl) {
295
+ payload =
296
+ (typeof instanceEl.getDefaultContext === 'function' && instanceEl.getDefaultContext()) ||
297
+ instanceEl.nodeset ||
298
+ null;
299
+ }
300
+
301
+ // Decide JSON vs XML by instance type (NOT by ref expression)
302
+ const isJsonInstance = instanceEl?.getAttribute?.('type') === 'json' || instanceEl?.type === 'json';
303
+
304
+ if (isJsonInstance) {
305
+ // Convert JSON lens nodes to plain JS before stringify
306
+ const plain = this._toPlainJson(payload);
307
+ // NOTE: you can pass spacing here if you want pretty output:
308
+ // return JSON.stringify(plain, null, 2);
309
+ return JSON.stringify(plain);
310
+ }
311
+
312
+ // --- XML / default path ---
313
+ // Keep existing XML behavior: if payload is a DOM node/document, serialize as XML.
314
+ // If payload is a string, return as-is.
315
+ if (typeof payload === 'string') return payload;
316
+
317
+ try {
318
+ if (payload && payload.nodeType) {
319
+ // Document => serialize documentElement, Node => serialize node
320
+ const node =
321
+ payload.nodeType === Node.DOCUMENT_NODE ? payload.documentElement : payload;
322
+ return new XMLSerializer().serializeToString(node);
323
+ }
324
+ } catch (_e) {
325
+ // fallthrough
326
+ }
327
+
328
+ // As a last resort for non-XML odd payloads:
329
+ return String(payload ?? '');
330
+ }
331
+
332
+ /**
333
+ * Convert a JSON lens node (JSONNode) or other value into plain JSON (no circular refs).
334
+ * This must NEVER return JSONNode objects.
335
+ *
336
+ * @param {any} v
337
+ * @returns {any} plain JSON value
338
+ */
339
+ _toPlainJson(v) {
340
+ if (v == null) return null;
341
+
342
+ // If it's already a plain primitive, keep it
343
+ const t = typeof v;
344
+ if (t === 'string' || t === 'number' || t === 'boolean') return v;
345
+
346
+ // JSON lens node (your JSONNode objects)
347
+ if (v.__jsonlens__ === true) {
348
+ return this._jsonLensNodeToPlain(v);
349
+ }
350
+
351
+ // Arrays: convert elements
352
+ if (Array.isArray(v)) {
353
+ return v.map(x => this._toPlainJson(x));
354
+ }
355
+
356
+ // Plain objects: best-effort convert (should be rare here)
357
+ // Avoid circular refs by only copying own enumerable props.
358
+ const out = {};
359
+ for (const [k, val] of Object.entries(v)) {
360
+ out[k] = this._toPlainJson(val);
361
+ }
362
+ return out;
363
+ }
364
+
365
+ /**
366
+ * Convert a single JSON lens node (JSONNode) into a plain JS value by traversing children.
367
+ *
368
+ * Assumptions (based on your JSON lens structure):
369
+ * - node.value holds the underlying JS value for leaf nodes
370
+ * - node.children is an array for arrays/objects
371
+ * - node.get(keyOrIndex) returns child node for objects/arrays
372
+ *
373
+ * This function intentionally does NOT touch node.parent.
374
+ *
375
+ * @param {any} node JSONNode
376
+ * @returns {any}
377
+ */
378
+ _jsonLensNodeToPlain(node) {
379
+ // If node.value is a primitive or null, return it
380
+ // (Many JSON lens implementations store actual scalar in .value)
381
+ const val = node.value;
382
+
383
+ if (
384
+ val === null ||
385
+ val === undefined ||
386
+ typeof val === 'string' ||
387
+ typeof val === 'number' ||
388
+ typeof val === 'boolean'
389
+ ) {
390
+ return val ?? null;
312
391
  }
313
- if (instanceType === 'xml') {
314
- const serializer = new XMLSerializer();
315
- return serializer.serializeToString(relevantNodes);
392
+
393
+ // If the node represents an array
394
+ if (Array.isArray(val)) {
395
+ // Prefer node.children if present; fall back to val (might be raw JS)
396
+ const kids = Array.isArray(node.children) ? node.children : val;
397
+ return kids.map(child => this._toPlainJson(child));
316
398
  }
317
- if (instanceType === 'json') {
318
- // console.warn('JSON serialization is not yet supported')
319
- return JSON.stringify(relevantNodes);
399
+
400
+ // If the node represents an object
401
+ if (typeof val === 'object') {
402
+ // If this is already a plain JS object (not a JSONNode), convert it
403
+ // but in lens setups val might be plain object while children are lens nodes.
404
+ const result = {};
405
+
406
+ // Prefer iterating keys from val
407
+ for (const key of Object.keys(val)) {
408
+ // Try lens navigation first
409
+ if (typeof node.get === 'function') {
410
+ const child = node.get(key);
411
+ result[key] = this._toPlainJson(child ?? val[key]);
412
+ } else {
413
+ result[key] = this._toPlainJson(val[key]);
414
+ }
415
+ }
416
+
417
+ return result;
320
418
  }
321
- if (instanceType === 'text') {
322
- return relevantNodes;
419
+ 1
420
+ // Fallback: last-resort scalar conversion
421
+ try {
422
+ if (typeof node.get === 'function') {
423
+ // Some lens nodes return scalar via get()
424
+ return this._toPlainJson(node.get());
425
+ }
426
+ } catch (_e) {
427
+ // ignore
323
428
  }
324
- throw new Error('unknown instance type ', instanceType);
429
+
430
+ return String(val);
325
431
  }
326
432
 
327
433
  _getHeaders() {
@@ -371,80 +477,86 @@ export class FxSubmission extends ForeElementMixin {
371
477
  * @param data
372
478
  * @private
373
479
  */
374
- _handleResponse(data, resolvedUrl, contentType) {
375
- // console.log('_handleResponse ', data);
376
-
480
+ async _handleResponse(data, resolvedUrl, contentType) {
377
481
  const targetInstance = this._getTargetInstance();
378
482
 
379
483
  if (this.replace === 'instance') {
380
- const targetInstance = this._getTargetInstance();
381
-
382
- // ### contentType handling
383
-
384
- if (contentType.includes('html')) {
385
- let effectiveData;
386
- if (data.nodeType) {
387
- effectiveData = data;
388
- }
389
- // ## try parsing
390
- try {
391
- effectiveData = new DOMParser().parseFromString(data, 'text/html');
392
- } catch {
393
- Fore.dispatch(this, 'error', { message: 'could not parse data as HTML' });
484
+ // ### contentType handling (HTML special-case)
485
+ if (contentType && contentType.includes('html')) {
486
+ let effectiveData = data;
487
+ if (!data?.nodeType) {
488
+ try {
489
+ effectiveData = new DOMParser().parseFromString(data, 'text/html');
490
+ } catch {
491
+ Fore.dispatch(this, 'error', { message: 'could not parse data as HTML' });
492
+ }
394
493
  }
395
-
396
494
  targetInstance.instanceData = effectiveData;
397
495
  }
398
- if (targetInstance) {
399
- if (this.targetref) {
400
- const [theTarget] = evaluateXPath(
496
+
497
+ if (!targetInstance) {
498
+ throw new Error(`target instance not found: ${targetInstance}`);
499
+ }
500
+
501
+ if (this.targetref) {
502
+ const [theTarget] = evaluateXPath(
401
503
  this.targetref,
402
504
  targetInstance.instanceData.firstElementChild,
403
505
  this,
404
- );
405
- console.log('theTarget', theTarget);
406
- if (
506
+ );
507
+
508
+ if (
407
509
  this.responseMediatype === 'application/xml' ||
408
510
  this.responseMediatype === 'text/html'
409
- ) {
410
- const clone = data.firstElementChild;
411
- const parent = theTarget.parentNode;
412
- parent.replaceChild(clone, theTarget);
413
- console.log('finally ', parent);
414
- }
415
- if (this.responseMediatype.startsWith('text/')) {
416
- theTarget.textContent = data;
417
- }
418
- if (this.responseMediatype === 'application/json') {
419
- console.warn('targetref is not supported for application/json responses');
420
- }
421
- } else if (this.into) {
422
- const [theTarget] = evaluateXPath(
511
+ ) {
512
+ const clone = data.firstElementChild;
513
+ const parent = theTarget.parentNode;
514
+ parent.replaceChild(clone, theTarget);
515
+ }
516
+ if (this.responseMediatype && this.responseMediatype.startsWith('text/')) {
517
+ theTarget.textContent = data;
518
+ }
519
+ if (this.responseMediatype === 'application/json') {
520
+ console.warn('targetref is not supported for application/json responses');
521
+ }
522
+ } else if (this.into) {
523
+ const [theTarget] = evaluateXPath(
423
524
  this.into,
424
525
  targetInstance.instanceData.firstElementChild,
425
526
  this,
426
- );
427
- console.log('theTarget', theTarget);
428
- if (data.nodeType === Node.DOCUMENT_NODE) {
429
- theTarget.appendChild(data.firstElementChild);
430
- } else {
431
- theTarget.innerHTML = data;
432
- }
527
+ );
528
+ if (data?.nodeType === Node.DOCUMENT_NODE) {
529
+ theTarget.appendChild(data.firstElementChild);
433
530
  } else {
434
- const instanceData = data;
435
- targetInstance.instanceData = instanceData;
436
- // console.log('### replaced instance ', this.getModel().instances);
437
- // console.log('### replaced instance ', targetInstance.instanceData);
531
+ theTarget.innerHTML = data;
438
532
  }
533
+ } else {
534
+ // ✅ This is the critical replace="instance" case
535
+ targetInstance.instanceData = data;
536
+ }
439
537
 
440
- // Skip any refreshes if the model is not yet inited
441
- if (this.model.inited) {
442
- this.model.updateModel(); // force update
443
- this.getOwnerForm().refresh(true);
538
+ // Skip any refreshes if the model is not yet inited
539
+ if (this.model.inited) {
540
+ // Rebuild model items / binds against the new instance root
541
+ this.model.updateModel();
542
+
543
+ // ✅ treat instance replacement as a structural change
544
+ const fore =
545
+ (typeof this.getOwnerForm === 'function' && this.getOwnerForm()) ||
546
+ this.closest('fx-fore') ||
547
+ this.getModel()?.parentNode;
548
+
549
+ if (fore) {
550
+ fore.someInstanceDataStructureChanged = true;
551
+ if (typeof fore.scanForNewTemplateExpressionsNextRefresh === 'function') {
552
+ fore.scanForNewTemplateExpressionsNextRefresh();
553
+ }
554
+ // ✅ IMPORTANT: await, otherwise tests/action-pipeline can out-run the refresh
555
+ await fore.refresh(true);
444
556
  }
445
- } else {
446
- throw new Error(`target instance not found: ${targetInstance}`);
447
557
  }
558
+
559
+ return;
448
560
  }
449
561
 
450
562
  if (this.replace === 'download') {
@@ -457,6 +569,7 @@ export class FxSubmission extends ForeElementMixin {
457
569
  downloadLink.setAttribute('href', `data:${contentType},${encodeURIComponent(data)}`);
458
570
  document.body.appendChild(downloadLink);
459
571
  downloadLink.click();
572
+ return;
460
573
  }
461
574
 
462
575
  if (this.replace === 'all') {
@@ -471,20 +584,17 @@ export class FxSubmission extends ForeElementMixin {
471
584
  document.close();
472
585
  window.location.href = resolvedUrl;
473
586
  }
474
- // document.getElementsByTagName('html')[0].innerHTML = data;
587
+ return;
475
588
  }
589
+
476
590
  if (this.replace === 'target') {
477
- // const target = this.getAttribute('target');
478
591
  const target = this._getProperty('target');
479
592
  const targetNode = document.querySelector(target);
480
593
  if (targetNode) {
481
- if (contentType.startsWith('text/html')) {
594
+ if (contentType && contentType.startsWith('text/html')) {
482
595
  targetNode.innerHTML = data;
483
596
  }
484
- if (this.responseMediatype.startsWith('image/svg')) {
485
- const parser = new DOMParser();
486
- const svgDoc = parser.parseFromString(data, 'image/svg+xml');
487
-
597
+ if (this.responseMediatype && this.responseMediatype.startsWith('image/svg')) {
488
598
  const objectURL = URL.createObjectURL(data);
489
599
  targetNode.src = objectURL;
490
600
  }
@@ -493,12 +603,13 @@ export class FxSubmission extends ForeElementMixin {
493
603
  message: `targetNode for selector ${target} not found`,
494
604
  });
495
605
  }
606
+ return;
496
607
  }
608
+
497
609
  if (this.replace === 'redirect') {
498
610
  window.location.href = data;
499
611
  }
500
612
  }
501
-
502
613
  /*
503
614
  _handleError() {
504
615
  // this.dispatch('submit-error', {});
package/src/fx-var.js CHANGED
@@ -18,18 +18,38 @@ export class FxVariable extends ForeElementMixin {
18
18
  this.valueQuery = '';
19
19
  this.value = null;
20
20
  this.precedingVariables = [];
21
+ // Re-entrancy guard for variable evaluation
22
+ this._isRefreshing = false;
23
+ // Cached typed value (Fonto sequence wrapper)
24
+ this._value = null;
21
25
  }
22
26
 
23
27
  connectedCallback() {
28
+ super.connectedCallback();
24
29
  this.name = this.getAttribute('name');
25
30
  this.valueQuery = this.getAttribute('value');
26
31
  }
27
32
 
28
33
  refresh() {
29
- const inscope = getInScopeContext(this, this.valueQuery);
34
+ // Prevent re-entrant refresh loops (variable evaluation can consult variables again)
35
+ if (this._isRefreshing) return;
30
36
 
31
- const values = evaluateXPath(this.valueQuery, inscope, this, this.precedingVariables);
32
- this.value = typedValueFactory(values, domFacade);
37
+ this._isRefreshing = true;
38
+ try {
39
+ // Ensure we have the current expression
40
+ this.valueQuery = this.getAttribute('value') || this.valueQuery || '';
41
+
42
+ const inscope = getInScopeContext(this, this.valueQuery);
43
+
44
+ // Evaluate using the preceding variables snapshot (do NOT pull live variables here)
45
+ const values = evaluateXPath(this.valueQuery, inscope, this, this.precedingVariables);
46
+
47
+ // Cache typed value for other computations to consume without triggering evaluation
48
+ this._value = typedValueFactory(values, domFacade);
49
+ this.value = this._value;
50
+ } finally {
51
+ this._isRefreshing = false;
52
+ }
33
53
  }
34
54
 
35
55
  /**
@@ -48,7 +68,11 @@ export class FxVariable extends ForeElementMixin {
48
68
 
49
69
  // Set precedingVariables based on inScopeVariables
50
70
  this.precedingVariables = Array.from(inScopeVariables.entries()).map(([name, variable]) => {
51
- return { name, value: variable.value };
71
+ // IMPORTANT: do not trigger evaluation while taking the snapshot
72
+ if (variable && variable._isRefreshing) {
73
+ return { name, value: null };
74
+ }
75
+ return { name, value: variable?._value ?? variable?.value ?? null };
52
76
  });
53
77
  }
54
78
  }