@jinntec/fore 3.3.2 → 4.0.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 (73) hide show
  1. package/README.md +98 -70
  2. package/dist/fore-dev.js +5593 -6832
  3. package/dist/fore.js +5602 -4887
  4. package/index.js +5 -10
  5. package/package.json +7 -7
  6. package/resources/fore.css +33 -0
  7. package/src/DependencyNotifyingDomFacade.js +90 -21
  8. package/src/DependentXPathQueries.js +15 -2
  9. package/src/ForeElementMixin.js +110 -16
  10. package/src/UndoManager.js +267 -0
  11. package/src/actions/abstract-action.js +71 -30
  12. package/src/actions/fx-action.js +5 -0
  13. package/src/actions/fx-append.js +3 -3
  14. package/src/actions/fx-commit-history.js +26 -0
  15. package/src/actions/fx-hide.js +1 -1
  16. package/src/actions/fx-insert.js +25 -22
  17. package/src/actions/fx-load.js +5 -5
  18. package/src/actions/fx-redo.js +58 -0
  19. package/src/actions/fx-refresh.js +2 -2
  20. package/src/actions/fx-reload.js +1 -1
  21. package/src/actions/fx-replace.js +1 -1
  22. package/src/actions/fx-send.js +27 -5
  23. package/src/actions/fx-setattribute.js +11 -7
  24. package/src/actions/fx-undo.js +58 -0
  25. package/src/createNodes.js +314 -0
  26. package/src/fore.js +53 -18
  27. package/src/functions/fx-functionlib.js +10 -10
  28. package/src/fx-bind.js +30 -18
  29. package/src/fx-fore.js +222 -200
  30. package/src/fx-instance.js +18 -1
  31. package/src/fx-model.js +236 -69
  32. package/src/fx-submission.js +37 -29
  33. package/src/fx-var.js +49 -13
  34. package/src/getInScopeContext.js +1 -1
  35. package/src/json/JSONDomFacade.js +1 -1
  36. package/src/json/JSONLens.js +2 -2
  37. package/src/ui/UIElement.js +18 -8
  38. package/src/ui/abstract-control.js +45 -3
  39. package/src/ui/fx-alert.js +4 -0
  40. package/src/ui/fx-case.js +1 -1
  41. package/src/ui/fx-container.js +3 -0
  42. package/src/ui/fx-control-menu.js +79 -11
  43. package/src/ui/fx-control.js +130 -41
  44. package/src/ui/fx-dialog.js +5 -0
  45. package/src/ui/fx-items.js +6 -6
  46. package/src/ui/fx-output.js +37 -1
  47. package/src/ui/fx-repeat.js +1065 -103
  48. package/src/ui/fx-repeatitem.js +4 -1
  49. package/src/ui/fx-switch.js +116 -3
  50. package/src/ui/fx-trigger.js +9 -4
  51. package/src/ui/fx-upload.js +10 -4
  52. package/src/ui/repeat-base.js +20 -12
  53. package/src/withDraggability.js +10 -1
  54. package/src/xpath-evaluation.js +30 -18
  55. package/src/xpath-path.js +122 -0
  56. package/src/xpath-util.js +11 -126
  57. package/src/actions/StringTpl.js +0 -17
  58. package/src/extract-predicate-deps.js +0 -57
  59. package/src/extractPredicateDependencies.js +0 -36
  60. package/src/json/lensFromNode.js +0 -5
  61. package/src/json-util.js +0 -27
  62. package/src/tools/adi.js +0 -1111
  63. package/src/tools/deprecation.md +0 -1
  64. package/src/tools/fx-action-log.js +0 -745
  65. package/src/tools/fx-devtools.js +0 -444
  66. package/src/tools/fx-dom-inspector.js +0 -610
  67. package/src/tools/fx-json-instance.js +0 -444
  68. package/src/tools/fx-log-item.js +0 -128
  69. package/src/tools/fx-log-settings.js +0 -533
  70. package/src/tools/fx-minimap.js +0 -203
  71. package/src/tools/helpers.js +0 -132
  72. package/src/ui/TemplateExpression.js +0 -12
  73. package/src/ui/fx-dom-inspector.js +0 -1255
package/src/fx-bind.js CHANGED
@@ -291,7 +291,12 @@ export class FxBind extends ForeElementMixin {
291
291
  }
292
292
 
293
293
  refs.forEach(ref => {
294
- const otherPath = getPath(ref, instanceId);
294
+ // `ref` may live in a different fx-instance than this bind's own `ref` (e.g. a
295
+ // `relevant` expression that reads `instance('other')/...`). Canonicalize its path
296
+ // using ITS OWN instance id, not the bind's -- reusing the bind's id here mislabels
297
+ // the dependency so the "changed" subgraph walk in recalculate() never finds it again.
298
+ const refInstanceId = this.model.getInstanceIdForNode?.(ref) ?? instanceId;
299
+ const otherPath = getPath(ref, refInstanceId);
295
300
 
296
301
  // keep old XML-only hack
297
302
  if (this.bindType === 'xml' && otherPath.endsWith('text()[1]')) return;
@@ -477,22 +482,34 @@ export class FxBind extends ForeElementMixin {
477
482
  }
478
483
 
479
484
  /**
480
- * Get the nodes that are referred by the given XPath expression
485
+ * Get the nodes that are referred by the given XPath expression, as evaluated against a
486
+ * single context node.
481
487
  *
482
488
  * @param {string} propertyExpr The XPath to get the referenced nodes from
489
+ * @param {Node} node The context node to evaluate propertyExpr against
483
490
  *
484
491
  * @return {Node[]} The nodes that are referenced by the XPath
485
492
  *
486
493
  * todo: DependencyNotifyingDomFacade reports back too much in some cases like 'a[1]' and 'a[1]/text[1]'
487
494
  */
488
- _getReferencesForProperty(propertyExpr) {
495
+ _getReferencesForProperty(propertyExpr, node) {
489
496
  if (propertyExpr) {
490
- return this.getReferences(propertyExpr);
497
+ return this.getReferences(propertyExpr, node);
491
498
  }
492
499
  return [];
493
500
  }
494
501
 
495
- getReferences(propertyExpr) {
502
+ /**
503
+ * NOTE: `node` must be the single row/context node this call is being made for (see
504
+ * _buildBindGraph()'s per-node loop) - NOT this.nodeset as a whole. Evaluating against the
505
+ * whole nodeset here previously meant that, for a bind matching N nodes (eg. N genericode
506
+ * `Row`s), building the dependency graph re-evaluated propertyExpr against all N nodes once
507
+ * per node in the outer loop - an O(N^2) blowup (802 Rows => ~643k evaluations) that measured
508
+ * at several seconds for UNTDID 1001 in demo/codelists/codelist-editor.html. It was also
509
+ * wrong: the touched-nodes set leaked in dependencies from every other node's evaluation, not
510
+ * just this node's own.
511
+ */
512
+ getReferences(propertyExpr, node) {
496
513
  // For XML, DependencyNotifyingDomFacade reliably reports the nodes touched during evaluation.
497
514
  // For JSON lens nodes, the domFacade hook does not fire (evaluation goes through our lens resolver),
498
515
  // so we must extract lookup tokens and resolve them explicitly.
@@ -504,16 +521,13 @@ export class FxBind extends ForeElementMixin {
504
521
  const touchedNodes = new Set();
505
522
  const tokens = this._extractJsonLookupTokens(propertyExpr);
506
523
 
507
- // Evaluate each token in the *current* context node (each item in nodeset)
508
- this.nodeset.forEach(node => {
509
- tokens.forEach(token => {
510
- try {
511
- const refs = evaluateXPathToNodes(token, node, this);
512
- refs.forEach(r => touchedNodes.add(r));
513
- } catch (_e) {
514
- // ignore: dependency extraction must never break bind initialization
515
- }
516
- });
524
+ tokens.forEach(token => {
525
+ try {
526
+ const refs = evaluateXPathToNodes(token, node, this);
527
+ refs.forEach(r => touchedNodes.add(r));
528
+ } catch (_e) {
529
+ // ignore: dependency extraction must never break bind initialization
530
+ }
517
531
  });
518
532
 
519
533
  return Array.from(touchedNodes.values());
@@ -522,9 +536,7 @@ export class FxBind extends ForeElementMixin {
522
536
  // XML path: use dom facade for accurate dependency tracking
523
537
  const touchedNodes = new Set();
524
538
  const domFacade = new DependencyNotifyingDomFacade(otherNode => touchedNodes.add(otherNode));
525
- this.nodeset.forEach(node => {
526
- evaluateXPathToString(propertyExpr, node, this, domFacade);
527
- });
539
+ evaluateXPathToString(propertyExpr, node, this, domFacade);
528
540
  return Array.from(touchedNodes.values());
529
541
  }
530
542
  _extractJsonLookupTokens(expr) {
package/src/fx-fore.js CHANGED
@@ -2,15 +2,12 @@ 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 {
6
- evaluateXPathToNodes,
7
- evaluateXPathToString,
8
- createNamespaceResolver,
9
- } from './xpath-evaluation.js';
5
+ import { evaluateXPathToNodes, evaluateXPathToString } from './xpath-evaluation.js';
10
6
  import getInScopeContext from './getInScopeContext.js';
11
7
  import { XPathUtil } from './xpath-util.js';
12
8
  import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js';
13
9
  import { FxBind } from './fx-bind.js';
10
+ import createNodes from './createNodes.js';
14
11
 
15
12
  /**
16
13
  * Makes the dirty state of the form.
@@ -24,21 +21,6 @@ const dirtyStates = {
24
21
  CLEAN: 'clean',
25
22
  DIRTY: 'dirty',
26
23
  };
27
- async function waitForFunctionLibs(rootEl) {
28
- const libs = Array.from(rootEl.querySelectorAll('fx-functionlib'));
29
- await Promise.all(libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve())));
30
- }
31
-
32
- /*
33
- * Determine whether a string is a valid Name
34
- *
35
- * @param {string} name
36
- * @returns {boolean} whether the name is a valid one
37
- */
38
- function isValidName(name) {
39
- const result = new DOMParser().parseFromString(`<${name}/>`, 'application/xml');
40
- return result.querySelector('parsererror') === null;
41
- }
42
24
 
43
25
  /**
44
26
  * Main class for Fore.Outermost container element for each Fore application.
@@ -347,7 +329,7 @@ export class FxFore extends HTMLElement {
347
329
  this.initialRun = true;
348
330
  this._scanForNewTemplateExpressionsNextRefresh = false;
349
331
  this.repeatsFromAttributesCreated = false;
350
- /*
332
+ /*
351
333
  this.validateOn = this.hasAttribute('validate-on')
352
334
  ? this.getAttribute('validate-on')
353
335
  : 'update';
@@ -392,11 +374,11 @@ export class FxFore extends HTMLElement {
392
374
  getDebugSnapshot(options = {}) {
393
375
  const model = this.model;
394
376
 
395
- const debugRefElements = Array.from(this.querySelectorAll('[ref]'))
396
- .filter(element => typeof element.getDebugInfo === 'function');
377
+ const debugRefElements = Array.from(this.querySelectorAll('[ref]')).filter(
378
+ element => typeof element.getDebugInfo === 'function',
379
+ );
397
380
 
398
- const bindingElements = debugRefElements
399
- .filter(element => element.localName === 'fx-bind');
381
+ const bindingElements = debugRefElements.filter(element => element.localName === 'fx-bind');
400
382
 
401
383
  const boundElementNames = new Set([
402
384
  'fx-control',
@@ -407,31 +389,32 @@ export class FxFore extends HTMLElement {
407
389
  'fx-switch',
408
390
  ]);
409
391
 
410
- const boundUiElements = debugRefElements
411
- .filter(element => boundElementNames.has(element.localName));
392
+ const boundUiElements = debugRefElements.filter(element =>
393
+ boundElementNames.has(element.localName),
394
+ );
412
395
 
413
396
  const bindings = bindingElements.map(element => element.getDebugInfo());
414
397
 
415
398
  const boundElements = boundUiElements.map(element => element.getDebugInfo());
416
399
 
417
400
  const submissions = Array.from(this.querySelectorAll('fx-submission'))
418
- .filter(element => typeof element.getDebugInfo === 'function')
419
- .map(element => element.getDebugInfo());
401
+ .filter(element => typeof element.getDebugInfo === 'function')
402
+ .map(element => element.getDebugInfo());
420
403
 
421
404
  return {
422
405
  fore: this.getDebugInfo?.() || null,
423
406
 
424
407
  model:
425
- model?.getDebugInfo?.({
426
- includeGraphs: options.includeGraphs === true,
427
- }) || null,
408
+ model?.getDebugInfo?.({
409
+ includeGraphs: options.includeGraphs === true,
410
+ }) || null,
428
411
 
429
412
  instances: model?.instances?.map(instance => instance.getDebugInfo?.()) || [],
430
413
  modelItems: model?.modelItems?.map(item => item.getDebugInfo?.()) || [],
431
414
 
432
415
  bindings,
433
416
  boundElements,
434
- submissions
417
+ submissions,
435
418
  };
436
419
  }
437
420
 
@@ -710,6 +693,11 @@ export class FxFore extends HTMLElement {
710
693
  registerVariables(child);
711
694
  }
712
695
  })(this);
696
+ // Model-internal evaluations (calculate and other facets) pass the fx-model as
697
+ // scope element to evaluateXPath — hand it the registry so model variables
698
+ // resolve there. FxModel does not extend ForeElementMixin, so it would
699
+ // otherwise fall through to implicit instance bindings only.
700
+ modelElement.inScopeVariables = variables;
713
701
 
714
702
  // Ensure all function libraries are loaded/registered before model construction,
715
703
  // so binds/calculate/XPath evaluations can safely call them.
@@ -830,6 +818,65 @@ export class FxFore extends HTMLElement {
830
818
  if (this.hasAttribute('show-confirmation')) {
831
819
  this.showConfirmation = true;
832
820
  }
821
+ if (this.hasAttribute('keyboard-shortcuts')) {
822
+ // opt-in: overrides native text-field undo within the form.
823
+ // Listens on document (not this element) so the shortcuts keep working when
824
+ // focus fell back to the body, e.g. right after an undo re-rendered the
825
+ // previously focused control.
826
+ this._undoRedoKeyListener = e => {
827
+ const target = e.composedPath ? e.composedPath()[0] : e.target;
828
+ if (
829
+ this.contains(target) ||
830
+ target === document.body ||
831
+ target === document.documentElement
832
+ ) {
833
+ this._handleUndoRedoKeys(e);
834
+ }
835
+ };
836
+ document.addEventListener('keydown', this._undoRedoKeyListener);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Commits any in-progress widget edit by blurring the focused element. Called before
842
+ * undo/redo so pending typing becomes its own undo step and no stale widget value
843
+ * can fire a late commit against the restored state (which would clear the redo stack).
844
+ */
845
+ flushPendingWidgetEdit() {
846
+ const active = document.activeElement;
847
+ if (active && active !== document.body && this.contains(active)) {
848
+ active.blur();
849
+ // In a window without OS focus (background window, e.g. a windowed test run),
850
+ // blur() does not dispatch a 'blur' event — nothing is really focused at the OS
851
+ // level — so fx-control's commit listener never runs and the pending edit is
852
+ // silently lost. Dispatch a synthetic blur to commit it; harmless if redundant
853
+ // (an unchanged value discards its undo capture).
854
+ if (!document.hasFocus()) {
855
+ active.dispatchEvent(new FocusEvent('blur'));
856
+ }
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Ctrl/Cmd+Z -> undo, Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y -> redo.
862
+ * Only active when the `keyboard-shortcuts` attribute is present.
863
+ */
864
+ async _handleUndoRedoKeys(e) {
865
+ if (!(e.ctrlKey || e.metaKey) || !this.model) return;
866
+ const key = e.key.toLowerCase();
867
+ const isRedo = (key === 'z' && e.shiftKey) || key === 'y';
868
+ if (!isRedo && key !== 'z') return;
869
+ e.preventDefault();
870
+
871
+ this.flushPendingWidgetEdit();
872
+ const done = isRedo ? this.model.redo() : this.model.undo();
873
+ if (!done) return;
874
+ this.model.updateModel();
875
+ await this.refresh(true);
876
+ Fore.dispatch(this, isRedo ? 'redo-done' : 'undo-done', {
877
+ canUndo: this.model.canUndo(),
878
+ canRedo: this.model.canRedo(),
879
+ });
833
880
  }
834
881
 
835
882
  /**
@@ -906,17 +953,13 @@ export class FxFore extends HTMLElement {
906
953
  }
907
954
 
908
955
  _injectDevtools() {
909
- if (this.ownerDocument.querySelector('fx-devtools')) {
910
- // There's already a devtools, so we can ignore this one.
911
- // One devtools can focus multiple fore elements
956
+ if (this.ownerDocument.querySelector('fx-lens')) {
957
+ // There's already a lens, so we can ignore this one.
958
+ // One lens can focus multiple fore elements
912
959
  return;
913
960
  }
914
961
  const { search } = window.location;
915
962
  const urlParams = new URLSearchParams(search);
916
- if (urlParams.has('inspect')) {
917
- const devtools = document.createElement('fx-devtools');
918
- document.body.appendChild(devtools);
919
- }
920
963
  if (urlParams.has('lens')) {
921
964
  const lens = document.createElement('fx-lens');
922
965
  document.body.appendChild(lens);
@@ -931,6 +974,7 @@ export class FxFore extends HTMLElement {
931
974
  * @param {string} localNameOfElement
932
975
  */
933
976
  signalChangeToElement(localNameOfElement) {
977
+ if (typeof localNameOfElement !== 'string' || !localNameOfElement) return;
934
978
  this._localNamesWithChanges.add(localNameOfElement);
935
979
  }
936
980
 
@@ -1015,6 +1059,10 @@ export class FxFore extends HTMLElement {
1015
1059
 
1016
1060
  disconnectedCallback() {
1017
1061
  this.removeEventListener('dragstart', this.dragstart);
1062
+ if (this._undoRedoKeyListener) {
1063
+ document.removeEventListener('keydown', this._undoRedoKeyListener);
1064
+ this._undoRedoKeyListener = null;
1065
+ }
1018
1066
  /*
1019
1067
  this.removeEventListener('model-construct-done', this._handleModelConstructDone);
1020
1068
  this.removeEventListener('message', this._displayMessage);
@@ -1061,11 +1109,23 @@ export class FxFore extends HTMLElement {
1061
1109
  if (isFullRefresh) {
1062
1110
  performance.mark('force-refresh-start');
1063
1111
  console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
1112
+ // A full refresh re-evaluates every ref anyway — pending structural-change
1113
+ // signals are consumed by it.
1114
+ this._localNamesWithChanges.clear();
1064
1115
  await Fore.refreshChildren(this, force);
1065
1116
  performance.mark('force-refresh-end');
1066
1117
  performance.measure('force-refresh', 'force-refresh-start', 'force-refresh-end');
1067
1118
  } else {
1119
+ // Evaluate UI variables synchronously so the batched-notification drain below
1120
+ // still starts in the same tick as refresh() itself — sync listeners (e.g.
1121
+ // action-performed) depend on that ordering.
1122
+ const variableConsumerRefreshes = this._refreshUIVariables();
1123
+ const structuralConsumerRefreshes = this._refreshStructuralDependents();
1068
1124
  await this._processBatchedNotifications();
1125
+ const consumerRefreshes = [...variableConsumerRefreshes, ...structuralConsumerRefreshes];
1126
+ if (consumerRefreshes.length > 0) {
1127
+ await Promise.all(consumerRefreshes);
1128
+ }
1069
1129
  }
1070
1130
 
1071
1131
  if (force === true || this.initialRun || this._scanForNewTemplateExpressionsNextRefresh) {
@@ -1079,12 +1139,6 @@ export class FxFore extends HTMLElement {
1079
1139
  this.initialRun = false;
1080
1140
  this.style.visibility = 'visible';
1081
1141
 
1082
- console.info(
1083
- `%c ✅ refresh-done on #${this.id}`,
1084
- 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1085
- this.getModel().modelItems,
1086
- );
1087
-
1088
1142
  // Record timing before dispatching 'refresh-done' since listeners (eg. fx-debugger)
1089
1143
  // may synchronously read this.debugInfo.lastRefresh in response to the event.
1090
1144
  this.debugInfo.lastRefresh = {
@@ -1126,6 +1180,105 @@ export class FxFore extends HTMLElement {
1126
1180
  }
1127
1181
  }
1128
1182
 
1183
+ /**
1184
+ * XForms 2.0: variables outside a model and not within an action are evaluated in
1185
+ * document order during a refresh. The full-refresh path covers them through the
1186
+ * Fore.refreshChildren traversal; this pass covers partial refreshes, where fx-var
1187
+ * elements were previously never re-evaluated (stale `$var` values).
1188
+ *
1189
+ * When a variable's value changed at this evaluation point, elements whose ref
1190
+ * expressions reference it are refreshed. This is invalidation at the evaluation
1191
+ * point — variables themselves are never re-evaluated reactively (spec: deletes and
1192
+ * predicate changes do not affect a variable until its next evaluation point).
1193
+ *
1194
+ * Implicit per-instance bindings ($default / $<instance-id>) are not fx-var elements
1195
+ * and are never touched here.
1196
+ *
1197
+ * Synchronous by design — refresh() must reach the batched-notification drain in the
1198
+ * same tick. Returns the consumer refresh() promises for the caller to await after
1199
+ * the drain.
1200
+ *
1201
+ * @returns {Promise[]}
1202
+ * @private
1203
+ */
1204
+ _refreshUIVariables() {
1205
+ const changedNames = [];
1206
+ this.querySelectorAll('fx-var').forEach(variable => {
1207
+ // Nested subforms evaluate their own variables
1208
+ if (variable.closest('fx-fore') !== this) return;
1209
+ // Model variables get their evaluation point in the model update cycle
1210
+ if (variable.closest('fx-model')) return;
1211
+ // Action-scoped variables are evaluated once per action execution
1212
+ for (let anc = variable.parentElement; anc && anc !== this; anc = anc.parentElement) {
1213
+ if (Fore.isActionElement(anc.nodeName)) return;
1214
+ }
1215
+ if (typeof variable.refreshAndReportChange !== 'function') return;
1216
+ if (variable.refreshAndReportChange()) {
1217
+ changedNames.push(variable.name);
1218
+ }
1219
+ });
1220
+
1221
+ if (changedNames.length === 0) return [];
1222
+
1223
+ const refreshPromises = [];
1224
+ // [value] too: fx-output's value attribute is evaluated outside evalInContext and
1225
+ // never reaches el.dependencies, so the raw attribute text is scanned as well.
1226
+ this.querySelectorAll('[ref],[value]').forEach(el => {
1227
+ if (el.closest('fx-fore') !== this) return;
1228
+ if (typeof el.refresh !== 'function') return;
1229
+ // Actions only evaluate during execution; fx-var got its evaluation point above
1230
+ if (Fore.isActionElement(el.nodeName) || el.nodeName === 'FX-VAR') return;
1231
+ const exprs = `${el.getAttribute('ref') ?? ''} ${el.getAttribute('value') ?? ''}`;
1232
+ if (
1233
+ changedNames.some(name => exprs.includes(`$${name}`)) ||
1234
+ (typeof el.dependencies?.isInvalidatedByVariableChange === 'function' &&
1235
+ el.dependencies.isInvalidatedByVariableChange(changedNames))
1236
+ ) {
1237
+ refreshPromises.push(el.refresh());
1238
+ }
1239
+ });
1240
+ return refreshPromises;
1241
+ }
1242
+
1243
+ /**
1244
+ * Consumes the structural-change signals produced by fx-insert/fx-delete/fx-append/
1245
+ * fx-setattribute (signalChangeToElement). Elements whose ref expressions may be
1246
+ * affected by child-list changes to nodes with the signalled local names are
1247
+ * refreshed — this catches refs that hold no observer on the changed node itself
1248
+ * (e.g. a count() over rows that were inserted or deleted, or an attribute that
1249
+ * did not exist when the ref was last evaluated).
1250
+ *
1251
+ * Pessimistic substring matching (DependentXPathQueries.isInvalidatedByChildlistChanges):
1252
+ * false positives are cheap extra refreshes.
1253
+ *
1254
+ * Synchronous by design, like _refreshUIVariables — returns the consumer refresh()
1255
+ * promises for the caller to await after the batched-notification drain.
1256
+ *
1257
+ * @returns {Promise[]}
1258
+ * @private
1259
+ */
1260
+ _refreshStructuralDependents() {
1261
+ if (this._localNamesWithChanges.size === 0) return [];
1262
+ const changedNames = Array.from(this._localNamesWithChanges);
1263
+ this._localNamesWithChanges.clear();
1264
+
1265
+ const refreshPromises = [];
1266
+ this.querySelectorAll('[ref]').forEach(el => {
1267
+ // Nested subforms consume their own signals
1268
+ if (el.closest('fx-fore') !== this) return;
1269
+ if (typeof el.refresh !== 'function') return;
1270
+ // Actions only evaluate during execution; fx-var snapshots are evaluation-point-only
1271
+ if (Fore.isActionElement(el.nodeName) || el.nodeName === 'FX-VAR') return;
1272
+ if (
1273
+ typeof el.dependencies?.isInvalidatedByChildlistChanges === 'function' &&
1274
+ el.dependencies.isInvalidatedByChildlistChanges(changedNames)
1275
+ ) {
1276
+ refreshPromises.push(el.refresh());
1277
+ }
1278
+ });
1279
+ return refreshPromises;
1280
+ }
1281
+
1129
1282
  /**
1130
1283
  * Process all batched notifications at the end of the refresh phase.
1131
1284
  * Async so that all control refresh() calls (which are themselves async) are awaited
@@ -1154,14 +1307,6 @@ export class FxFore extends HTMLElement {
1154
1307
  }
1155
1308
  refreshPromises.push(uiElement.refresh(true));
1156
1309
  }
1157
- const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]'));
1158
- if (nonrelevant) {
1159
- nonrelevant.forEach(el => {
1160
- if (el.refresh) {
1161
- refreshPromises.push(el.refresh());
1162
- }
1163
- });
1164
- }
1165
1310
  if (entry.observers) {
1166
1311
  entry.observers.forEach(observer => {
1167
1312
  if (typeof observer.update === 'function') {
@@ -1171,6 +1316,21 @@ export class FxFore extends HTMLElement {
1171
1316
  }
1172
1317
  });
1173
1318
 
1319
+ // Nonrelevant subtrees are skipped by the normal recursive refresh traversal
1320
+ // (Fore.refreshChildren), so they need an explicit refresh() here to pick up any
1321
+ // other pending state changes. This only needs to run once per batch, not once per
1322
+ // entry -- doing it inside the forEach above re-scanned the whole form and re-pushed
1323
+ // a refresh() per nonrelevant element for EVERY batched entry, an O(entries x
1324
+ // nonrelevant) blowup that hangs/crashes the tab once more than a couple hundred
1325
+ // nodes change relevance at once (e.g. a cross-instance fx-bind[relevant] driving a
1326
+ // large fx-repeat from a single filter keystroke).
1327
+ const nonrelevant = Array.from(this.querySelectorAll('[nonrelevant]'));
1328
+ nonrelevant.forEach(el => {
1329
+ if (el.refresh) {
1330
+ refreshPromises.push(el.refresh());
1331
+ }
1332
+ });
1333
+
1174
1334
  this._processTemplateExpressions();
1175
1335
  this.batchedNotifications.clear();
1176
1336
 
@@ -1999,6 +2159,11 @@ export class FxFore extends HTMLElement {
1999
2159
  }
2000
2160
  }
2001
2161
  }
2162
+ /**
2163
+ * Create Nodes from an XPath
2164
+ * @param {string} ref
2165
+ * @param {Element} referenceNode
2166
+ */
2002
2167
  _createNodes(ref, referenceNode) {
2003
2168
  if (!ref || !referenceNode) return null;
2004
2169
 
@@ -2013,6 +2178,8 @@ export class FxFore extends HTMLElement {
2013
2178
  const ownerDoc =
2014
2179
  referenceNode.nodeType === Node.DOCUMENT_NODE ? referenceNode : referenceNode.ownerDocument;
2015
2180
 
2181
+ if (!ownerDoc) return null;
2182
+
2016
2183
  const baseElement =
2017
2184
  referenceNode.nodeType === Node.DOCUMENT_NODE
2018
2185
  ? referenceNode.documentElement
@@ -2020,152 +2187,7 @@ export class FxFore extends HTMLElement {
2020
2187
  ? referenceNode.ownerElement
2021
2188
  : referenceNode;
2022
2189
 
2023
- if (!ownerDoc) return null;
2024
-
2025
- const baseNamespace = baseElement?.namespaceURI || null;
2026
- const namespaceResolver = createNamespaceResolver(xpath, this);
2027
-
2028
- const parseName = token => {
2029
- const raw = token.trim();
2030
-
2031
- if (raw.startsWith('@')) {
2032
- const attrToken = raw.slice(1);
2033
- if (attrToken.startsWith('*:')) {
2034
- return { isAttribute: true, namespaceURI: null, localName: attrToken.substring(2) };
2035
- }
2036
- if (attrToken.includes(':')) {
2037
- const [prefix, localName] = attrToken.split(':');
2038
- return {
2039
- isAttribute: true,
2040
- namespaceURI: prefix === '*' ? null : namespaceResolver(prefix) || null,
2041
- localName,
2042
- };
2043
- }
2044
- return { isAttribute: true, namespaceURI: null, localName: attrToken };
2045
- }
2046
-
2047
- if (raw.startsWith('*:')) {
2048
- return { isAttribute: false, namespaceURI: baseNamespace, localName: raw.substring(2) };
2049
- }
2050
- if (raw.includes(':')) {
2051
- const [prefix, localName] = raw.split(':');
2052
- return {
2053
- isAttribute: false,
2054
- namespaceURI: prefix === '*' ? baseNamespace : namespaceResolver(prefix) || baseNamespace,
2055
- localName,
2056
- };
2057
- }
2058
- return { isAttribute: false, namespaceURI: baseNamespace, localName: raw };
2059
- };
2060
-
2061
- const parseStep = step => {
2062
- const trimmed = step.trim();
2063
- const nameMatch = trimmed.match(/^([^\[]+)/);
2064
- const token = nameMatch ? nameMatch[1].trim() : trimmed;
2065
- const predicates = [];
2066
-
2067
- const predicateRegex = /\[\s*@([^\]\s=]+)\s*=\s*(['"])(.*?)\2\s*\]/g;
2068
- let match;
2069
- while ((match = predicateRegex.exec(trimmed)) !== null) {
2070
- predicates.push({ name: match[1], value: match[3] });
2071
- }
2072
- return { token, predicates };
2073
- };
2074
-
2075
- const splitSteps = xpath => {
2076
- /**
2077
- * @type {string[]}
2078
- */
2079
- const steps = [];
2080
- let scratch = '';
2081
- let isInPredicate = false;
2082
- for (const char of xpath.split('')) {
2083
- if (char === '[') {
2084
- isInPredicate = true;
2085
- scratch += char;
2086
- continue;
2087
- }
2088
- if (char === ']') {
2089
- scratch += char;
2090
- isInPredicate = false;
2091
- continue;
2092
- }
2093
- if (!isInPredicate) {
2094
- // Just add to the scratch. Do not check for slashes within predicates
2095
- if (char === '/') {
2096
- // Consume this path step
2097
- if (scratch) {
2098
- steps.push(scratch);
2099
- }
2100
- scratch = '';
2101
- continue;
2102
- }
2103
- }
2104
- scratch += char;
2105
- }
2106
-
2107
- if (scratch) {
2108
- // Flush it
2109
- steps.push(scratch);
2110
- }
2111
-
2112
- return steps;
2113
- };
2114
-
2115
- const steps = splitSteps(xpath)
2116
- .map(step => step.trim())
2117
- .filter(step => step && step !== '.');
2118
-
2119
- if (!steps.length) return null;
2120
-
2121
- let subtreeRoot = null;
2122
- let current = null;
2123
-
2124
- for (const rawStep of steps) {
2125
- const { token, predicates } = parseStep(rawStep);
2126
- if (!token || token === '.') {
2127
- continue;
2128
- }
2129
-
2130
- const parsed = parseName(token);
2131
-
2132
- if (!isValidName(parsed.localName)) {
2133
- // This did not result in a valid name. Stop.
2134
- console.warn(
2135
- `Creating node for the XPath ${xpath} failed because the part ${parsed.localName} is not a valid Name.`,
2136
- );
2137
- return;
2138
- }
2139
-
2140
- if (parsed.isAttribute) {
2141
- if (!current) {
2142
- const attr = ownerDoc.createAttribute(parsed.localName);
2143
- return attr;
2144
- }
2145
- current.setAttribute(parsed.localName, '');
2146
- continue;
2147
- }
2148
-
2149
- const element = parsed.namespaceURI
2150
- ? ownerDoc.createElementNS(parsed.namespaceURI, parsed.localName)
2151
- : ownerDoc.createElement(parsed.localName);
2152
-
2153
- for (const predicate of predicates) {
2154
- const attrName = predicate.name.includes(':')
2155
- ? predicate.name.split(':')[1]
2156
- : predicate.name;
2157
- element.setAttribute(attrName, predicate.value);
2158
- }
2159
-
2160
- if (!subtreeRoot) {
2161
- subtreeRoot = element;
2162
- } else {
2163
- current.appendChild(element);
2164
- }
2165
- current = element;
2166
- }
2167
-
2168
- return subtreeRoot;
2190
+ return createNodes(ref, baseElement, this);
2169
2191
  }
2170
2192
 
2171
2193
  _handleDragStart(event) {