@jinntec/fore 2.9.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fx-model.js CHANGED
@@ -2,9 +2,10 @@ import { DepGraph } from './dep_graph.js';
2
2
  import { Fore } from './fore.js';
3
3
  import './fx-instance.js';
4
4
  import { ModelItem } from './modelitem.js';
5
- import { getPath } from './xpath-path.js';
5
+ import { parseJsonRef, getPath } from './xpath-path.js';
6
6
  import { evaluateXPath, evaluateXPathToBoolean, evaluateXPathToNodes } from './xpath-evaluation.js';
7
7
  import { XPathUtil } from './xpath-util.js';
8
+ import { getLensForNode } from './json/JSONNode.js';
8
9
 
9
10
  /**
10
11
  * The model of this Fore scope. It holds all the intances, binding, submissions and custom
@@ -128,6 +129,7 @@ export class FxModel extends HTMLElement {
128
129
  */
129
130
  static lazyCreateModelItem(model, ref, node, formElement) {
130
131
  const instanceId = XPathUtil.resolveInstance(formElement, ref);
132
+ const instance = model.getInstance(instanceId);
131
133
  const fore = model.formElement;
132
134
 
133
135
  if (fore?.createNodes && (node === null || node === undefined)) {
@@ -136,43 +138,62 @@ export class FxModel extends HTMLElement {
136
138
  model.registerModelItem(mi);
137
139
  return mi;
138
140
  }
139
-
140
141
  if (node === null || node === undefined) return null;
141
142
 
142
- let targetNode = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
143
+ let targetNode = Array.isArray(node) ? node[0] : node;
143
144
 
145
+ // Wrap JSON primitives / raw values into a lens node when needed
146
+ if (instance.type === 'json') {
147
+ const parentLens = instance.nodeset;
148
+ const parsedRef = parseJsonRef(ref);
149
+ if (parsedRef && parsedRef.steps && parsedRef.steps.length > 0) {
150
+ const key = parsedRef.steps[parsedRef.steps.length - 1];
151
+ targetNode = getLensForNode(targetNode, parentLens, key, instanceId);
152
+ }
153
+ }
154
+
155
+ // Compute canonical path
144
156
  let path = null;
145
- if (targetNode?.nodeType) {
157
+ if (targetNode?.nodeType || targetNode?.__jsonlens__) {
146
158
  path = getPath(targetNode, instanceId);
147
159
  }
148
160
 
149
- // Check if a ModelItem with the same path already exists
161
+ const isLensObject =
162
+ !!targetNode && typeof targetNode === 'object' &&
163
+ typeof targetNode.get === 'function' && typeof targetNode.set === 'function';
164
+
165
+ // If ModelItem for same path exists, RETARGET it (node OR lens)
150
166
  if (path) {
151
167
  const existingModelItem = model.modelItems.find(mi => mi.path === path);
152
168
  if (existingModelItem) {
153
- // Update the node reference if needed
154
- if (existingModelItem.node !== targetNode) {
155
- existingModelItem.node = targetNode;
169
+ if (isLensObject) {
170
+ if (existingModelItem.lens !== targetNode) {
171
+ existingModelItem.lens = targetNode;
172
+ existingModelItem.node = null;
173
+ }
174
+ } else {
175
+ if (existingModelItem.node !== targetNode) {
176
+ existingModelItem.node = targetNode;
177
+ existingModelItem.lens = null;
178
+ }
156
179
  }
157
180
  return existingModelItem;
158
181
  }
159
182
  }
160
183
 
161
184
  const mi = new ModelItem(
162
- path,
163
- ref,
164
- targetNode,
165
- model.getBindForElement(targetNode),
166
- instanceId,
167
- fore,
185
+ path,
186
+ ref,
187
+ targetNode,
188
+ model.getBindForElement(targetNode),
189
+ instanceId,
190
+ fore,
168
191
  );
169
192
  mi.isSynthetic = true;
170
193
 
171
- // console.log('new ModelItem is instanceof ModelItem ', mi instanceof ModelItem);
172
194
  model.registerModelItem(mi);
173
195
  return mi;
174
196
  }
175
-
176
197
  /**
177
198
  * modelConstruct starts actual processing of the model by
178
199
  *
@@ -199,11 +220,40 @@ export class FxModel extends HTMLElement {
199
220
  // Wait until all the instances are built
200
221
  await Promise.all(promises);
201
222
  this.instances = Array.from(instances);
223
+ // Build in-memory variable bindings for instances (Variant A: no <fx-var> DOM nodes).
224
+ // These bindings are merged into XPath variable resolution by xpath-evaluation.js.
225
+ if (this.formElement) {
226
+ const bindings = Object.create(null);
227
+
228
+ // $default always points to the model's default instance (first instance)
229
+ // IMPORTANT: For JSON instances, bind RAW JS root so `?` lookup works.
230
+ try {
231
+ const defInst = this.getDefaultInstance();
232
+ if (defInst) {
233
+ const t = (defInst.getAttribute && defInst.getAttribute('type')) || defInst.type;
234
+ bindings.default = t === 'json' ? defInst.getInstanceData() : defInst.getDefaultContext();
235
+ }
236
+ } catch (_e) {
237
+ // ignore
238
+ }
239
+ // Also expose $<id> for explicitly id'ed instances
240
+ this.instances.forEach(inst => {
241
+ const explicitId = inst.getAttribute('id');
242
+ if (!explicitId) return;
243
+ // Do not overwrite $default binding; $default remains the first instance
244
+ if (explicitId === 'default') return;
245
+
246
+ const t = (inst.getAttribute && inst.getAttribute('type')) || inst.type;
247
+ bindings[explicitId] = t === 'json' ? inst.getInstanceData() : inst.getDefaultContext();
248
+ });
249
+ this.formElement._instanceVarBindings = bindings;
250
+ }
202
251
  // console.log('_modelConstruct this.instances ', this.instances);
203
252
  // Await until the model-construct-done event is handled off
204
253
  this.modelConstructed = true;
205
254
  await Fore.dispatch(this, 'model-construct-done', { model: this });
206
255
  this.inited = true;
256
+
207
257
  this.updateModel();
208
258
  } else {
209
259
  // ### if there's no instance one will created
@@ -225,10 +275,87 @@ export class FxModel extends HTMLElement {
225
275
  }
226
276
 
227
277
  registerModelItem(modelItem) {
228
- // console.log('ModelItem registered ', modelItem);
229
- this.modelItems.push(modelItem);
230
- }
278
+ if (!modelItem) return null;
279
+
280
+ const path = modelItem.path;
281
+
282
+ const resetComputedState = mi => {
283
+ // Tabula rasa for computed facets; keep identity (boundControls/observers)
284
+ mi.readonly = ModelItem.READONLY_DEFAULT;
285
+ mi.relevant = ModelItem.RELEVANT_DEFAULT;
286
+ mi.required = ModelItem.REQUIRED_DEFAULT;
287
+ mi.constraint = ModelItem.CONSTRAINT_DEFAULT;
288
+ mi.type = ModelItem.TYPE_DEFAULT;
289
+
290
+ // common extras in Fore's ModelItem
291
+ if ('valid' in mi) mi.valid = true;
292
+ if ('enabled' in mi) mi.enabled = true;
293
+ mi.changed = false;
294
+
295
+ // observer/dependency bookkeeping (safe to reset; will be rebuilt)
296
+ if (mi.dependencies && typeof mi.dependencies.clear === 'function') mi.dependencies.clear();
297
+ if (mi.stateExpressions) mi.stateExpressions = {};
298
+ if (mi.state) mi.state = {};
299
+ };
300
+
301
+ const retarget = (target, source) => {
302
+ // point to current backing node/lens
303
+ if (source.lens) {
304
+ target.lens = source.lens;
305
+ target.node = null;
306
+ } else if (source.node) {
307
+ target.node = source.node;
308
+ target.lens = null;
309
+ }
310
+
311
+ // keep metadata current
312
+ if (source.ref) target.ref = source.ref;
313
+ if (source.bind) target.bind = source.bind;
314
+ if (source.instanceId) target.instanceId = source.instanceId;
315
+ if (source.fore) target.fore = source.fore;
316
+
317
+ // ✅ IMPORTANT: do NOT copy value!
318
+ // For XML nodes, assigning `value` sets `node.textContent` and can delete child elements.
319
+
320
+ resetComputedState(target);
321
+
322
+ if (!target.boundControls) target.boundControls = [];
323
+ };
324
+
325
+ // ---- rebuild reuse-by-path (approach A) ----
326
+ if (path && this._prevModelItemsByPath) {
327
+ const prev = this._prevModelItemsByPath.get(path);
328
+ if (prev) {
329
+ retarget(prev, modelItem);
231
330
 
331
+ if (!this.modelItems.includes(prev)) {
332
+ this.modelItems.push(prev);
333
+ }
334
+
335
+ this._prevModelItemsByPath.delete(path);
336
+ return prev;
337
+ }
338
+ }
339
+
340
+ // ---- normal path ----
341
+ if (!path) {
342
+ // No path => can't reuse; keep as-is
343
+ this.modelItems.push(modelItem);
344
+ return modelItem;
345
+ }
346
+
347
+ const existing = this.modelItems.find(mi => mi.path === path);
348
+ if (!existing) {
349
+ // New canonical item
350
+ resetComputedState(modelItem);
351
+ this.modelItems.push(modelItem);
352
+ return modelItem;
353
+ }
354
+
355
+ // Re-target canonical item
356
+ retarget(existing, modelItem);
357
+ return existing;
358
+ }
232
359
  /**
233
360
  * update action triggering the update cycle
234
361
  */
@@ -253,50 +380,78 @@ export class FxModel extends HTMLElement {
253
380
  * @param {Node} node - The node for which to remove the model item
254
381
  */
255
382
  removeModelItem(node) {
256
- const index = this.modelItems.findIndex(mi => mi.node === node);
257
- // The model item is not always there. Might be the case if a node is 'skipped' during rendering. All paths jump over it.
258
- // It may still have descendants that can have model items
383
+ if (!node) return;
384
+
385
+ // Support both XML nodes (mi.node) and JSON lens nodes (mi.lens)
386
+ const index = this.modelItems.findIndex(mi => mi.node === node || mi.lens === node);
387
+
388
+ // The model item is not always there. Might be the case if a node is 'skipped' during rendering.
389
+ // It may still have descendants that can have model items.
259
390
  if (index !== -1) {
391
+ const mi = this.modelItems[index];
392
+
393
+ // IMPORTANT:
394
+ // Before removing the ModelItem, enqueue all observers (bound UI controls) for refresh.
395
+ // Otherwise, deleting a bound node can orphan controls (eg. fx-group) because their ModelItem
396
+ // disappears before the refresh scheduler can reach them.
397
+ try {
398
+ const fore = this.formElement || this.parentNode || mi.fore;
399
+ if (fore && typeof fore.addToBatchedNotifications === 'function' && mi && mi.observers) {
400
+ mi.observers.forEach(observer => {
401
+ if (observer && typeof observer.refresh === 'function') {
402
+ fore.addToBatchedNotifications(observer);
403
+ }
404
+ });
405
+ }
406
+ } catch (_e) {
407
+ // ignore
408
+ }
409
+
260
410
  this.modelItems.splice(index, 1);
261
411
  }
262
412
 
263
- for (const child of Array.from(node.childNodes)) {
264
- this.removeModelItem(child);
413
+ // Recurse for XML descendants only
414
+ if (node.childNodes) {
415
+ for (const child of Array.from(node.childNodes)) {
416
+ this.removeModelItem(child);
417
+ }
265
418
  }
266
419
  }
267
420
 
268
421
  rebuild() {
269
422
  console.log(`🔷 rebuild() '${this.fore.id}'`);
270
423
 
271
- this.mainGraph = new DepGraph(false); // do: should be moved down below binds.length check but causes errors in tests.
424
+ // Build a lookup for existing ModelItems so we can reuse them by path (approach A)
425
+ const prevItems = Array.isArray(this.modelItems) ? this.modelItems : [];
426
+ this._prevModelItemsByPath = new Map();
427
+ prevItems.forEach(mi => {
428
+ if (mi && mi.path) this._prevModelItemsByPath.set(mi.path, mi);
429
+ });
430
+
431
+ this.mainGraph = new DepGraph(false);
272
432
  this.modelItems = [];
273
433
 
274
- // trigger recursive initialization of the fx-bind elements
275
434
  const binds = this.querySelectorAll('fx-model > fx-bind');
276
435
  if (binds.length === 0) {
277
- // console.log('skipped model update');
278
436
  this.skipUpdate = true;
437
+ this._prevModelItemsByPath = null;
279
438
  return;
280
439
  }
281
440
 
282
- binds.forEach(bind => {
283
- bind.init(this);
284
- });
441
+ binds.forEach(bind => bind.init(this));
285
442
 
286
443
  if (this.formElement.createNodes) {
287
- // initData should be running here as well: we just got a whole new instance that may be
288
- // incomplete
289
444
  this.formElement.initData();
290
445
  }
291
446
 
447
+ // Drop unused previous ModelItems (not re-registered this rebuild)
448
+ this._prevModelItemsByPath = null;
449
+
292
450
  console.log('mainGraph', this.mainGraph);
293
451
  console.log('rebuild mainGraph calc order', this.mainGraph.overallOrder());
294
452
 
295
- // this.dispatchEvent(new CustomEvent('rebuild-done', {detail: {maingraph: this.mainGraph}}));
296
453
  Fore.dispatch(this, 'rebuild-done', { maingraph: this.mainGraph });
297
- // console.log('mainGraph', this.mainGraph);
298
454
  }
299
-
300
455
  /**
301
456
  * recalculation of all modelItems. Uses dependency graph to determine order of computation.
302
457
  *
@@ -415,58 +570,64 @@ export class FxModel extends HTMLElement {
415
570
  * @param {string} path - the canonical XPath of the node
416
571
  */
417
572
  compute(node, path) {
418
- const modelItem = this.getModelItem(node);
419
- if (modelItem && path.includes(':')) {
420
- const property = path.split(':')[1];
421
- if (property) {
422
- /*
423
- if (property === 'readonly') {
424
- // make sure that calculated items are always readonly
425
- if(modelItem.bind['calculate']){
426
- modelItem.readonly = true;
427
- }else {
428
- const expr = modelItem.bind[property];
429
- const compute = evaluateXPathToBoolean(expr, modelItem.node, this);
430
- modelItem.readonly = compute;
431
- }
432
- }
433
- */
434
- const expr = modelItem.bind[property];
435
- if (property === 'calculate') {
436
- const compute = evaluateXPath(expr, modelItem.node, this);
437
- modelItem.value = compute;
438
- modelItem.readonly = true; // calculated nodes are always readonly
439
- modelItem.notify(); // Notify observers directly
440
- } else if (property !== 'constraint' && property !== 'type') {
441
- /*
442
- console.log(
443
- 'recalculating path ',
444
- path,
445
- ' Expr:',
446
- expr,
447
- 'modelitem value',
448
- modelItem.node.textContent,
449
- );
450
- */
451
- // ### re-compute the Boolean value of all facets expect 'constraint' and 'type' which are handled in revalidate()
452
- if (expr) {
453
- const compute = evaluateXPathToBoolean(expr, modelItem.node, this);
454
- modelItem[property] = compute;
455
- // modelItem.notify(); // Notify observers directly
456
- this.fore.addToBatchedNotifications(modelItem);
457
- /*
458
- console.log(
459
- `recalculating path ${path} - Expr:'${expr}' computed`,
460
- modelItem[property],
461
- );
462
- */
463
- }
573
+ // Nodes in dep graphs can be transient during JSON insert/rebuild windows.
574
+ // Preserve depGraph semantics, but avoid crashing when a ModelItem is momentarily missing.
575
+
576
+ // Resolve facet property (eg. "$data/movies[3]/title:relevant")
577
+ const isFacetPath = typeof path === 'string' && path.includes(':');
578
+ if (!isFacetPath) return;
579
+
580
+ const property = path.split(':')[1];
581
+ if (!property) return;
582
+
583
+ // Try to resolve the model item primarily by node, but fall back to canonical path.
584
+ // The depGraph stores node data that may not be the same object identity after lens rebuild.
585
+ let modelItem = this.getModelItem(node);
586
+
587
+ if (!modelItem && node && (node.__jsonlens__ === true || typeof node.getPath === 'function')) {
588
+ try {
589
+ const instanceId = node.instanceId || XPathUtil.resolveInstance(this, path);
590
+ const canonical = getPath(node, instanceId);
591
+ modelItem = this.getModelItem(canonical);
592
+ } catch (_e) {
593
+ // ignore
594
+ }
595
+ }
596
+
597
+ // If still missing, fall back to the prefix path of the facet node.
598
+ // eg. "$data/movies[3]/title:relevant" => "$data/movies[3]/title"
599
+ if (!modelItem) {
600
+ const basePath = path.substring(0, path.indexOf(':'));
601
+ modelItem = this.getModelItem(basePath);
602
+ }
603
+
604
+ // ✅ Minimal fix: don't crash the update cycle if the ModelItem doesn't exist.
605
+ // This can happen during insert/delete when rebuild retargeting is in progress.
606
+ if (!modelItem) {
607
+ return;
608
+ }
609
+
610
+ if (modelItem && typeof path === 'string') {
611
+ const expr = modelItem.bind ? modelItem.bind[property] : null;
612
+ const context = modelItem.node || modelItem.lens;
613
+
614
+ if (property === 'calculate') {
615
+ const compute = evaluateXPath(expr, context, this);
616
+ modelItem.value = compute;
617
+ modelItem.readonly = true; // calculated nodes are always readonly
618
+ modelItem.notify(); // Notify observers directly
619
+ } else if (property !== 'constraint' && property !== 'type') {
620
+ // ### re-compute the Boolean value of all facets expect 'constraint' and 'type' which are handled in revalidate()
621
+ if (expr) {
622
+ const compute = evaluateXPathToBoolean(expr, context, this);
623
+ modelItem[property] = compute;
624
+ // modelItem.notify(); // Notify observers directly
625
+ this.fore.addToBatchedNotifications(modelItem);
464
626
  }
465
627
  }
466
628
  this.computes += 1;
467
629
  }
468
630
  }
469
-
470
631
  /**
471
632
  * Iterates all modelItems to calculate the validation status.
472
633
  *
@@ -569,7 +730,18 @@ export class FxModel extends HTMLElement {
569
730
  * @returns {ModelItem|null}
570
731
  */
571
732
  getModelItem(nodeOrPath) {
572
- return this.modelItems.find(mi => mi.node === nodeOrPath || mi.path === nodeOrPath) || null;
733
+ if (nodeOrPath == null) return null;
734
+
735
+ // Path lookup
736
+ if (typeof nodeOrPath === 'string') {
737
+ const key = nodeOrPath.includes(':') ? nodeOrPath.substring(0, nodeOrPath.indexOf(':')) : nodeOrPath;
738
+ return this.modelItems.find(mi => mi.path === key) || null;
739
+ }
740
+
741
+ // Node/lens lookup
742
+ return (
743
+ this.modelItems.find(mi => mi.node === nodeOrPath || mi.lens === nodeOrPath) || null
744
+ );
573
745
  }
574
746
 
575
747
  /**
@@ -603,45 +775,63 @@ export class FxModel extends HTMLElement {
603
775
  * @returns {import('./fx-instance.js').FxInstance}
604
776
  */
605
777
  getInstance(id) {
606
- // console.log('getInstance ', id);
607
- // console.log('instances ', this.instances);
608
- // console.log('instances array ',Array.from(this.instances));
778
+ let found = null;
609
779
 
610
- let found;
780
+ // default instance is first instance in this model
611
781
  if (id === 'default') {
612
782
  found = this.instances[0];
613
783
  }
784
+
614
785
  // ### lookup in local instances first
615
786
  if (!found) {
616
787
  const instArray = Array.from(this.instances);
617
788
  found = instArray.find(inst => inst.id === id);
618
789
  }
619
- // ### lookup in parent Fore if present
790
+
791
+ // ### lookup in parent Fore if present (shared instances)
620
792
  if (!found) {
621
- // const parentFore = this.fore.parentNode.closest('fx-fore');
622
793
  const parentFore =
623
- this.fore.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
624
- ? this.fore.parentNode.host.closest('fx-fore')
625
- : this.fore.parentNode.closest('fx-fore');
794
+ this.fore.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
795
+ ? this.fore.parentNode.host.closest('fx-fore')
796
+ : this.fore.parentNode.closest('fx-fore');
797
+
626
798
  if (parentFore) {
627
- // console.log('shared instances from parent', this.parentNode.id);
628
799
  const parentInstances = parentFore.getModel().instances;
629
- const shared = parentInstances.filter(shared => shared.hasAttribute('shared'));
630
- found = shared.find(found => found.id === id);
800
+ const shared = parentInstances.filter(inst => inst.hasAttribute('shared'));
801
+ found = shared.find(inst => inst.id === id);
631
802
  }
632
803
  }
633
- // search for shared instances in the whole document
804
+
805
+ // ### search for shared instances in the light DOM (legacy)
634
806
  if (!found) {
635
807
  found = document.querySelector(`fx-instance[id="${id}"][shared]`);
636
808
  }
637
- if (found) {
638
- return found;
639
- }
640
- if (id === 'default') {
641
- return this.getDefaultInstance(); // if id is not found always defaults to first in doc order
809
+
810
+ // ### NEW: search for shared instances inside other fx-fore shadowRoots
811
+ // This is required when a fore keeps its model/instances in its own shadow DOM
812
+ // and sibling fores want to consume that instance via instance('id').
813
+ if (!found) {
814
+ const allFores = Array.from(document.querySelectorAll('fx-fore'));
815
+ for (const fore of allFores) {
816
+ // light DOM inside fore (in case someone authoring without shadow)
817
+ const light = fore.querySelector?.(`fx-instance[id="${id}"][shared]`);
818
+ if (light) {
819
+ found = light;
820
+ break;
821
+ }
822
+
823
+ // shadow DOM inside fore (common in your demos)
824
+ const shadow = fore.shadowRoot?.querySelector?.(`fx-instance[id="${id}"][shared]`);
825
+ if (shadow) {
826
+ found = shadow;
827
+ break;
828
+ }
829
+ }
642
830
  }
831
+
832
+ if (found) return found;
833
+
643
834
  if (!found && this.fore.strict) {
644
- // return this.getDefaultInstance(); // if id is not found always defaults to first in doc order
645
835
  Fore.dispatch(this, 'error', {
646
836
  origin: this,
647
837
  message: `Instance '${id}' does not exist`,