@mintjamsinc/ichigojs 0.1.68 → 0.1.70

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.
@@ -8399,6 +8399,31 @@
8399
8399
  * is replaced, the binding is set to null/undefined, or the bindings are destroyed.
8400
8400
  */
8401
8401
  #externalSubscriptions = new Map();
8402
+ /**
8403
+ * Recompute callbacks for computed properties registered on these bindings.
8404
+ * Maps a computed property name to a function that recomputes and caches its value.
8405
+ * Only the bindings instance that owns the computed (typically the root) holds an entry;
8406
+ * child bindings resolve computed reads by delegating to their parent through the proxy.
8407
+ */
8408
+ #computedRecomputers = new Map();
8409
+ /**
8410
+ * The set of computed properties whose cached value is stale and must be recomputed on next
8411
+ * access (pull-based evaluation). Populated by markComputedDirty when a dependency changes,
8412
+ * and cleared as each computed is resolved.
8413
+ */
8414
+ #dirtyComputeds = new Set();
8415
+ /**
8416
+ * Guard set used to detect re-entrant (circular) computed resolution. While a computed is
8417
+ * being recomputed its name is held here; a nested read of the same computed returns the
8418
+ * currently cached (stale) value instead of recursing infinitely.
8419
+ */
8420
+ #resolvingComputeds = new Set();
8421
+ /**
8422
+ * The plain backing object behind the #local proxy. Kept as a direct reference so the cached
8423
+ * value of a computed property can be read without triggering pull-based re-evaluation
8424
+ * (see peekComputed).
8425
+ */
8426
+ #store = {};
8402
8427
  /**
8403
8428
  * Creates a new instance of VBindings.
8404
8429
  * @param parent The parent bindings, if any.
@@ -8410,8 +8435,13 @@
8410
8435
  if (this.#logger?.isDebugEnabled) {
8411
8436
  this.#logger.debug(`VBindings created. Parent: ${this.#parent ? 'yes' : 'no'}`);
8412
8437
  }
8413
- this.#local = new Proxy({}, {
8438
+ this.#local = new Proxy(this.#store, {
8414
8439
  get: (obj, key) => {
8440
+ // Pull-based evaluation: if this key is a dirty computed property, recompute it now
8441
+ // so the read returns a fresh value (synchronously, even before the update microtask).
8442
+ if (typeof key === 'string') {
8443
+ this.#resolveComputedIfDirty(key);
8444
+ }
8415
8445
  if (Reflect.has(obj, key)) {
8416
8446
  return Reflect.get(obj, key);
8417
8447
  }
@@ -8669,6 +8699,79 @@
8669
8699
  registerWritableComputed(key, setter) {
8670
8700
  this.#writableComputeds.set(key, setter);
8671
8701
  }
8702
+ /**
8703
+ * Registers a computed property for pull-based (lazy) evaluation. The provided recompute
8704
+ * callback is invoked the first time the property is read after it has been marked dirty
8705
+ * (or during the pre-render flush), and is expected to update the cached value (typically
8706
+ * via setSilent).
8707
+ * @param key The computed property name.
8708
+ * @param recompute The callback that recomputes and caches the property's value.
8709
+ */
8710
+ registerComputed(key, recompute) {
8711
+ this.#computedRecomputers.set(key, recompute);
8712
+ }
8713
+ /**
8714
+ * Marks a computed property as dirty so it will be recomputed on next access.
8715
+ * @param key The computed property name.
8716
+ */
8717
+ markComputedDirty(key) {
8718
+ this.#dirtyComputeds.add(key);
8719
+ }
8720
+ /**
8721
+ * Reads the currently cached value of a computed property without triggering pull-based
8722
+ * re-evaluation. Used by the recompute routine to obtain the previous value for change
8723
+ * detection. Falls back to the parent bindings when the key is not stored locally.
8724
+ * @param key The computed property name.
8725
+ * @returns The cached value, or undefined if not cached.
8726
+ */
8727
+ peekComputed(key) {
8728
+ if (Object.prototype.hasOwnProperty.call(this.#store, key)) {
8729
+ return this.#store[key];
8730
+ }
8731
+ return this.#parent?.peekComputed(key);
8732
+ }
8733
+ /**
8734
+ * Forces resolution of every computed property currently marked dirty. Called before the DOM
8735
+ * diff and watcher notification so that the set of changed identifiers is complete.
8736
+ */
8737
+ flushDirtyComputeds() {
8738
+ for (const key of [...this.#dirtyComputeds]) {
8739
+ this.#resolveComputedIfDirty(key);
8740
+ }
8741
+ }
8742
+ /**
8743
+ * Resolves a computed property if its cached value is dirty (pull-based evaluation), by
8744
+ * invoking its registered recompute callback. Computed→computed chains resolve naturally:
8745
+ * reading another computed inside the getter re-enters this method for that key. Re-entrant
8746
+ * resolution of the same key (a circular dependency) is detected and short-circuited, leaving
8747
+ * the previously cached value in place.
8748
+ * @param key The property name to resolve.
8749
+ */
8750
+ #resolveComputedIfDirty(key) {
8751
+ const recompute = this.#computedRecomputers.get(key);
8752
+ if (!recompute) {
8753
+ // Not a computed owned by these bindings; a parent (if any) resolves it when the value
8754
+ // is read through the proxy delegation in the get trap.
8755
+ return;
8756
+ }
8757
+ if (!this.#dirtyComputeds.has(key)) {
8758
+ return;
8759
+ }
8760
+ if (this.#resolvingComputeds.has(key)) {
8761
+ this.#logger?.warn(`Circular dependency detected while resolving computed property '${key}'.`);
8762
+ return;
8763
+ }
8764
+ this.#resolvingComputeds.add(key);
8765
+ try {
8766
+ // Clear the dirty flag before recomputing so reads of this same key during recomputation
8767
+ // (other than a true cycle) do not attempt to resolve it again.
8768
+ this.#dirtyComputeds.delete(key);
8769
+ recompute();
8770
+ }
8771
+ finally {
8772
+ this.#resolvingComputeds.delete(key);
8773
+ }
8774
+ }
8672
8775
  /**
8673
8776
  * Manually adds an identifier to the set of changed identifiers.
8674
8777
  * This is useful for computed properties that need to mark themselves as changed
@@ -10270,9 +10373,18 @@
10270
10373
  #sourceName;
10271
10374
  #useOfSyntax = false; // Track if 'of' syntax was used
10272
10375
  /**
10273
- * Map to track rendered items by their keys
10376
+ * Ordered list of currently rendered items.
10377
+ *
10378
+ * This is intentionally an ordered array of { key, vNode } entries rather
10379
+ * than a Map<key, VNode>. The :key attribute is a *reconciliation hint*
10380
+ * used to identify and reuse the same logical row across re-renders — it is
10381
+ * not the identity of the rendered set. Keying the rendered set by a Map
10382
+ * would make it structurally impossible to hold two rows that resolve to
10383
+ * the same key, which would silently drop application data. The most
10384
+ * fundamental invariant of a list directive is "N items in => N rows out",
10385
+ * so the rendered set must be a positional list that can carry duplicates.
10274
10386
  */
10275
- #renderedItems = new Map();
10387
+ #renderedItems = [];
10276
10388
  /**
10277
10389
  * Previous iterations to detect changes
10278
10390
  */
@@ -10395,11 +10507,11 @@
10395
10507
  destroy() {
10396
10508
  // Clean up all rendered items
10397
10509
  // First destroy all VNodes (calls @unmount hooks), then remove from DOM
10398
- for (const vNode of this.#renderedItems.values()) {
10510
+ for (const { vNode } of this.#renderedItems) {
10399
10511
  vNode.destroy();
10400
10512
  }
10401
10513
  // Then remove DOM nodes
10402
- for (const vNode of this.#renderedItems.values()) {
10514
+ for (const { vNode } of this.#renderedItems) {
10403
10515
  const range = vNode.fragmentRange;
10404
10516
  if (range) {
10405
10517
  range.remove();
@@ -10410,7 +10522,7 @@
10410
10522
  vNode.node.parentNode.removeChild(vNode.node);
10411
10523
  }
10412
10524
  }
10413
- this.#renderedItems.clear();
10525
+ this.#renderedItems = [];
10414
10526
  this.#previousIterations = [];
10415
10527
  }
10416
10528
  /**
@@ -10451,7 +10563,24 @@
10451
10563
  this.#previousIterations = iterations;
10452
10564
  }
10453
10565
  /**
10454
- * Key-based diffing for efficient DOM updates
10566
+ * Key-based diffing for efficient DOM updates.
10567
+ *
10568
+ * Reconciliation model
10569
+ * --------------------
10570
+ * The :key attribute is treated as a *hint* for reusing the same logical
10571
+ * row across re-renders, not as the identity of the rendered set. The
10572
+ * previously rendered rows are placed into a pool keyed by :key (a queue
10573
+ * per key, so equal keys can hold more than one row). Each incoming
10574
+ * iteration then claims a row from its key's queue when one is available,
10575
+ * otherwise a fresh row is created. Whatever stays in the pool at the end
10576
+ * is genuinely gone and is destroyed and removed.
10577
+ *
10578
+ * This guarantees the directive's most fundamental invariant — "N items in
10579
+ * => N rows out" — even when the application supplies duplicate keys. We
10580
+ * still warn on duplicates because they make reuse ambiguous (reordering
10581
+ * becomes positional rather than identity-stable), but we never silently
10582
+ * drop the application's data, and no row is ever orphaned: every old row
10583
+ * is either reused or explicitly removed.
10455
10584
  */
10456
10585
  #updateList(newIterations) {
10457
10586
  const parent = this.#vNode.anchorNode?.parentNode;
@@ -10459,22 +10588,38 @@
10459
10588
  if (!parent || !anchor) {
10460
10589
  throw new Error('v-for element must have a parent and anchor');
10461
10590
  }
10462
- const newRenderedItems = new Map();
10463
- // Track which keys are still needed and detect duplicates
10464
- const neededKeys = new Set();
10591
+ // Build a reuse pool from the currently rendered rows. A queue per key
10592
+ // (FIFO) lets duplicate keys reuse multiple rows: the first incoming
10593
+ // occurrence claims the first existing row, the second claims the next,
10594
+ // and so on.
10595
+ const pool = new Map();
10596
+ for (const { key, vNode } of this.#renderedItems) {
10597
+ let queue = pool.get(key);
10598
+ if (!queue) {
10599
+ queue = [];
10600
+ pool.set(key, queue);
10601
+ }
10602
+ queue.push(vNode);
10603
+ }
10604
+ // Decide, for each incoming iteration in order, whether it reuses an
10605
+ // existing row or needs a new one. Reused rows are taken out of the
10606
+ // pool so that what remains afterwards is exactly the set to remove.
10465
10607
  const seenKeys = new Set();
10466
- for (const ctx of newIterations) {
10467
- if (seenKeys.has(ctx.key)) {
10468
- console.warn(`[ichigo.js] Duplicate key detected in v-for: "${ctx.key}". This may cause unexpected behavior. Keys should be unique.`);
10608
+ const plan = [];
10609
+ for (const context of newIterations) {
10610
+ if (seenKeys.has(context.key)) {
10611
+ console.warn(`[ichigo.js] Duplicate key detected in v-for: "${context.key}". All entries are still rendered, but reordering may be unstable. Keys should be unique.`);
10469
10612
  }
10470
- seenKeys.add(ctx.key);
10471
- neededKeys.add(ctx.key);
10613
+ seenKeys.add(context.key);
10614
+ const queue = pool.get(context.key);
10615
+ const reused = queue && queue.length ? queue.shift() : undefined;
10616
+ plan.push({ context, reused });
10472
10617
  }
10473
- // Remove items that are no longer needed
10618
+ // Remove rows that were not reused.
10474
10619
  // First destroy VNodes (calls @unmount hooks while DOM is still accessible)
10475
10620
  const nodesToRemove = [];
10476
- for (const [key, vNode] of this.#renderedItems) {
10477
- if (!neededKeys.has(key)) {
10621
+ for (const queue of pool.values()) {
10622
+ for (const vNode of queue) {
10478
10623
  nodesToRemove.push(vNode);
10479
10624
  vNode.destroy();
10480
10625
  }
@@ -10493,11 +10638,12 @@
10493
10638
  parentOfNode.removeChild(vNode.node);
10494
10639
  }
10495
10640
  }
10496
- // Add or reorder items
10641
+ // Add or reorder rows, building the new ordered rendered set.
10642
+ const newRenderedItems = [];
10497
10643
  let prevNode = anchor;
10498
- for (const context of newIterations) {
10644
+ for (const { context, reused } of plan) {
10499
10645
  const { key } = context;
10500
- let vNode = this.#renderedItems.get(key);
10646
+ let vNode = reused;
10501
10647
  if (!vNode) {
10502
10648
  // Create new item
10503
10649
  const clone = this.#cloneNode();
@@ -10529,7 +10675,7 @@
10529
10675
  if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
10530
10676
  const range = VFragmentRange.insert(parent, prevNode.nextSibling, 'vfor-fragment', clone);
10531
10677
  vNode.fragmentRange = range;
10532
- newRenderedItems.set(key, vNode);
10678
+ newRenderedItems.push({ key, vNode });
10533
10679
  vNode.forceUpdate();
10534
10680
  prevNode = range.lastNode;
10535
10681
  continue;
@@ -10543,12 +10689,12 @@
10543
10689
  else {
10544
10690
  parent.appendChild(nodeToInsert);
10545
10691
  }
10546
- newRenderedItems.set(key, vNode);
10692
+ newRenderedItems.push({ key, vNode });
10547
10693
  vNode.forceUpdate();
10548
10694
  }
10549
10695
  else {
10550
10696
  // Reuse existing item
10551
- newRenderedItems.set(key, vNode);
10697
+ newRenderedItems.push({ key, vNode });
10552
10698
  // Update bindings
10553
10699
  this.#updateItemBindings(vNode, context);
10554
10700
  // For fragment-backed iterations, move the entire range atomically.
@@ -10574,7 +10720,7 @@
10574
10720
  // Advance prevNode to this iteration's last DOM node
10575
10721
  prevNode = vNode.fragmentRange?.lastNode ?? vNode.anchorNode ?? vNode.node;
10576
10722
  }
10577
- // Update rendered items map
10723
+ // Update the ordered rendered set
10578
10724
  this.#renderedItems = newRenderedItems;
10579
10725
  }
10580
10726
  /**
@@ -13811,7 +13957,11 @@
13811
13957
  #initializeBindings() {
13812
13958
  // Create bindings with change tracking
13813
13959
  this.#bindings = new VBindings({
13814
- onChange: () => {
13960
+ onChange: (identifier) => {
13961
+ // Pull-based invalidation: mark dependent computed properties dirty synchronously
13962
+ // so a subsequent synchronous read returns a fresh value, then schedule the
13963
+ // batched DOM update for the next microtask.
13964
+ this.#markDirtyComputeds(identifier);
13815
13965
  this.#scheduleUpdate();
13816
13966
  },
13817
13967
  vApplication: this
@@ -13861,8 +14011,17 @@
13861
14011
  }
13862
14012
  }
13863
14013
  }
13864
- // Add computed properties (initialization mode)
13865
- this.#recomputeProperties(true);
14014
+ // Register computed properties for pull-based (lazy) evaluation. Each computed is
14015
+ // recomputed on first access after being marked dirty, rather than eagerly in the update
14016
+ // microtask. We mark them all dirty and resolve once here so that initial values are
14017
+ // cached and recorded as changes for the first render.
14018
+ if (this.#options.computed) {
14019
+ for (const key of Object.keys(this.#options.computed)) {
14020
+ this.#bindings.registerComputed(key, () => this.#recomputeOne(key));
14021
+ this.#bindings.markComputedDirty(key);
14022
+ }
14023
+ }
14024
+ this.#flushDirtyComputeds();
13866
14025
  }
13867
14026
  /**
13868
14027
  * Initializes watchers from the watch option.
@@ -13913,8 +14072,9 @@
13913
14072
  * Executes an immediate DOM update.
13914
14073
  */
13915
14074
  #update() {
13916
- // Re-evaluate computed properties that depend on changed values
13917
- this.#recomputeProperties();
14075
+ // Resolve any computed properties still marked dirty (those not already pulled by a
14076
+ // synchronous read) so that the set of changed identifiers is complete before the DOM diff.
14077
+ this.#flushDirtyComputeds();
13918
14078
  // Apply computed source path mappings to changes
13919
14079
  // This converts paths like "model.elements[0].executionListeners"
13920
14080
  // to "selectedElement.executionListeners" when selectedElement points to model.elements[0]
@@ -13959,110 +14119,116 @@
13959
14119
  }
13960
14120
  }
13961
14121
  /**
13962
- * Recursively recomputes computed properties based on changed identifiers.
13963
- * @param isInitialization - If true, computes all computed properties regardless of dependencies
14122
+ * Marks computed properties as dirty (pull-based invalidation) when a dependency changes.
14123
+ * Uses the statically analyzed dependency graph; because computed→computed dependencies are
14124
+ * flattened to their underlying reactive paths during analysis, a single change marks every
14125
+ * transitively dependent computed dirty in one pass. The actual recomputation is deferred until
14126
+ * the value is read (see #recomputeOne).
14127
+ * @param identifier The changed identifier reported by the bindings change tracker.
13964
14128
  */
13965
- #recomputeProperties(isInitialization = false) {
13966
- if (!this.#options.computed) {
14129
+ #markDirtyComputeds(identifier) {
14130
+ if (!this.#options.computed || !this.#bindings) {
13967
14131
  return;
13968
14132
  }
13969
- const computed = new Set();
13970
- const processing = new Set();
13971
- // Gather all changed identifiers, including all parent paths
13972
- // e.g., for "model.elements[0].messageRef", also add:
13973
- // "model.elements[0]", "model.elements", "model"
13974
- const allChanges = new Set();
13975
- this.#bindings?.changes.forEach(id => {
13976
- allChanges.add(id);
13977
- // Add all parent paths by progressively stripping from the end
13978
- let path = id;
13979
- while (path.length > 0) {
13980
- // Find last separator (either '[' or '.')
13981
- const bracketIdx = path.lastIndexOf('[');
13982
- const dotIdx = path.lastIndexOf('.');
13983
- const lastSep = Math.max(bracketIdx, dotIdx);
13984
- if (lastSep === -1) {
13985
- break;
13986
- }
13987
- path = path.substring(0, lastSep);
13988
- if (path.length > 0) {
13989
- allChanges.add(path);
13990
- }
13991
- }
13992
- });
13993
- // Helper function to recursively compute a property
13994
- const compute = (key) => {
13995
- // Skip if already computed in this update cycle
13996
- if (computed.has(key)) {
13997
- return;
13998
- }
13999
- // Detect circular dependency
14000
- if (processing.has(key)) {
14001
- this.#logger.error(`Circular dependency detected for computed property '${key}'`);
14002
- return;
14003
- }
14004
- processing.add(key);
14005
- // Get the dependencies for this computed property
14133
+ for (const key of Object.keys(this.#computedDependencies)) {
14006
14134
  const deps = this.#computedDependencies[key] || [];
14007
- // First, recursively compute any dependent computed properties.
14008
- // This must happen before the change check so that computed→computed
14009
- // dependency chains are resolved and allChanges is up-to-date.
14010
- for (const dep of deps) {
14011
- if (this.#options.computed[dep]) {
14012
- compute(dep);
14013
- }
14014
- }
14015
- // If none of the dependencies have changed, skip recomputation (unless it's initialization).
14016
- // Checked after recursive computation to detect transitive changes through computed properties.
14017
- if (!isInitialization && !deps.some(dep => allChanges.has(dep))) {
14018
- computed.add(key);
14019
- return;
14135
+ if (deps.some(dep => this.#dependencyAffectedBy(dep, identifier))) {
14136
+ this.#bindings.markComputedDirty(key);
14020
14137
  }
14021
- // Now compute this property
14022
- const computedFn = VApplication.#getComputedGetter(this.#options.computed[key]);
14023
- try {
14024
- const oldValue = this.#bindings?.get(key);
14025
- const newValue = computedFn.call(this.#bindings?.raw);
14026
- // Check if the value actually changed
14027
- let hasChanged = oldValue !== newValue;
14028
- // For arrays, always update (VBindings will detect length changes via its length cache)
14029
- if (!hasChanged && Array.isArray(newValue)) {
14030
- hasChanged = true;
14031
- }
14032
- if (hasChanged) {
14033
- // Use setSilent to avoid triggering onChange during computed property updates
14034
- // Then mark the computed property as changed so UI depending on it will update
14035
- this.#bindings?.setSilent(key, newValue);
14036
- this.#bindings?.markChanged(key);
14037
- allChanges.add(key);
14038
- // Track source path mapping for computed property values
14039
- // This allows changes like "model.elements[0].x" to be mapped to "selectedElement.x"
14040
- if (typeof newValue === 'object' && newValue !== null) {
14041
- const sourcePath = ReactiveProxy.getPath(newValue);
14042
- if (sourcePath) {
14043
- // Remove old mapping for this computed property
14044
- for (const [path, name] of this.#computedSourcePaths) {
14045
- if (name === key) {
14046
- this.#computedSourcePaths.delete(path);
14047
- break;
14048
- }
14138
+ }
14139
+ }
14140
+ /**
14141
+ * Determines whether a change to `changePath` affects a computed dependency `dep`, taking both
14142
+ * path directions into account:
14143
+ * - an exact match;
14144
+ * - `changePath` is a descendant of `dep` (e.g. dep "cartItems", change "cartItems.0.quantity")
14145
+ * — a nested mutation of the dependency;
14146
+ * - `dep` is a descendant of `changePath` (e.g. dep "user.name", change "user") — the container
14147
+ * holding the dependency was replaced wholesale.
14148
+ * Local and global path aliases (e.g. computed source paths) are also honored via the bindings'
14149
+ * own alias-aware matcher.
14150
+ * @param dep The dependency path declared by a computed property.
14151
+ * @param changePath The identifier reported by the bindings change tracker.
14152
+ */
14153
+ #dependencyAffectedBy(dep, changePath) {
14154
+ if (changePath === dep) {
14155
+ return true;
14156
+ }
14157
+ if (changePath.startsWith(dep + '.') || changePath.startsWith(dep + '[')) {
14158
+ return true;
14159
+ }
14160
+ if (dep.startsWith(changePath + '.') || dep.startsWith(changePath + '[')) {
14161
+ return true;
14162
+ }
14163
+ // Fall back to alias-aware matching for aliased paths (computed source paths, props, etc.).
14164
+ return this.#bindings.doesChangeMatchIdentifier(changePath, dep);
14165
+ }
14166
+ /**
14167
+ * Forces resolution of all computed properties currently marked dirty so that the set of
14168
+ * changed identifiers is complete before watcher notification and the DOM diff. Computeds that
14169
+ * were already pulled by a synchronous read earlier in the cycle are no longer dirty and are
14170
+ * skipped, so each computed is recomputed at most once per update cycle.
14171
+ */
14172
+ #flushDirtyComputeds() {
14173
+ this.#bindings?.flushDirtyComputeds();
14174
+ }
14175
+ /**
14176
+ * Recomputes a single computed property and updates its cached value. Registered with the
14177
+ * bindings as the pull-based recompute callback, so it runs lazily the first time the property
14178
+ * is read after being marked dirty (or during the pre-render flush). Computed→computed chains
14179
+ * resolve naturally and order-independently: reading a dependent computed inside the getter
14180
+ * triggers its own lazy resolution through the bindings proxy.
14181
+ *
14182
+ * When the value actually changes, the computed name is recorded as a change so that
14183
+ * dependency-precise DOM updates and watcher notifications still fire, and the source-path
14184
+ * mapping is refreshed so nested changes to the underlying object map back to the computed.
14185
+ * @param key The computed property name.
14186
+ */
14187
+ #recomputeOne(key) {
14188
+ if (!this.#options.computed || !this.#bindings) {
14189
+ return;
14190
+ }
14191
+ const def = this.#options.computed[key];
14192
+ if (!def) {
14193
+ return;
14194
+ }
14195
+ const computedFn = VApplication.#getComputedGetter(def);
14196
+ try {
14197
+ // Read the previous value without triggering re-resolution, for change detection.
14198
+ const oldValue = this.#bindings.peekComputed(key);
14199
+ const newValue = computedFn.call(this.#bindings.raw);
14200
+ // Check if the value actually changed.
14201
+ let hasChanged = oldValue !== newValue;
14202
+ // For arrays, always treat as changed (VBindings detects length changes via its length
14203
+ // cache). This preserves the precise-update behavior of the previous eager implementation.
14204
+ if (!hasChanged && Array.isArray(newValue)) {
14205
+ hasChanged = true;
14206
+ }
14207
+ // Cache the value silently so the read returns it without re-triggering reactivity.
14208
+ this.#bindings.setSilent(key, newValue);
14209
+ if (hasChanged) {
14210
+ // Mark the computed property as changed so UI and watchers depending on it update.
14211
+ this.#bindings.markChanged(key);
14212
+ // Track source path mapping for computed property values.
14213
+ // This allows changes like "model.elements[0].x" to be mapped to "selectedElement.x".
14214
+ if (typeof newValue === 'object' && newValue !== null) {
14215
+ const sourcePath = ReactiveProxy.getPath(newValue);
14216
+ if (sourcePath) {
14217
+ // Remove old mapping for this computed property
14218
+ for (const [path, name] of this.#computedSourcePaths) {
14219
+ if (name === key) {
14220
+ this.#computedSourcePaths.delete(path);
14221
+ break;
14049
14222
  }
14050
- // Add new mapping
14051
- this.#computedSourcePaths.set(sourcePath, key);
14052
14223
  }
14224
+ // Add new mapping
14225
+ this.#computedSourcePaths.set(sourcePath, key);
14053
14226
  }
14054
14227
  }
14055
14228
  }
14056
- catch (error) {
14057
- this.#logger.error(`Error evaluating computed property '${key}': ${error}`);
14058
- }
14059
- computed.add(key);
14060
- processing.delete(key);
14061
- };
14062
- // Compute all properties; the recursive logic inside compute() handles
14063
- // dependency ordering and skips properties whose dependencies did not change.
14064
- for (const key of Object.keys(this.#computedDependencies)) {
14065
- compute(key);
14229
+ }
14230
+ catch (error) {
14231
+ this.#logger.error(`Error evaluating computed property '${key}': ${error}`);
14066
14232
  }
14067
14233
  }
14068
14234
  /**