@mintjamsinc/ichigojs 0.1.67 → 0.1.69

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/dist/ichigo.cjs CHANGED
@@ -7981,6 +7981,13 @@
7981
7981
  * This allows retrieving the source path of an object for computed property mapping.
7982
7982
  */
7983
7983
  static proxyPaths = new WeakMap();
7984
+ /**
7985
+ * Dispatchers per (target, path). Every target reached while walking a proxy
7986
+ * subtree registers an entry pointing at the same dispatcher as the outermost
7987
+ * proxy of that subtree, so callers can look up the dispatcher from any
7988
+ * intermediate proxy when subscribing.
7989
+ */
7990
+ static dispatchers = new WeakMap();
7984
7991
  /**
7985
7992
  * A Map to store path aliases.
7986
7993
  * Key: alias path (e.g., "editingNestedStep.steps")
@@ -7995,9 +8002,10 @@
7995
8002
  * @param target The object to make reactive.
7996
8003
  * @param onChange Callback function to call when the object changes. Receives the full path of the changed property.
7997
8004
  * @param path The current path in the object tree (used internally for nested objects).
8005
+ * @param inheritedDispatcher Internal: the dispatcher inherited from an enclosing create() call when wrapping a nested target. External callers must omit this.
7998
8006
  * @returns A reactive proxy of the target object.
7999
8007
  */
8000
- static create(target, onChange, path = '') {
8008
+ static create(target, onChange, path = '', inheritedDispatcher) {
8001
8009
  // If the target is not an object or is null, return it as-is
8002
8010
  if (typeof target !== 'object' || target === null) {
8003
8011
  return target;
@@ -8017,6 +8025,13 @@
8017
8025
  if (this.proxyToTarget.has(target)) {
8018
8026
  return target;
8019
8027
  }
8028
+ // Resolve (and register) the dispatcher for this (target, path).
8029
+ // Nested create() calls inherit the dispatcher from the enclosing call so
8030
+ // that a single dispatcher fans out changes at any depth of the subtree.
8031
+ const dispatcher = this.resolveDispatcher(target, path, inheritedDispatcher);
8032
+ if (onChange) {
8033
+ dispatcher.listeners.add(onChange);
8034
+ }
8020
8035
  // Check if we already have a proxy for this target with this path
8021
8036
  let pathMap = this.proxyCache.get(target);
8022
8037
  if (pathMap) {
@@ -8048,7 +8063,7 @@
8048
8063
  // Build the nested path
8049
8064
  const keyStr = String(key);
8050
8065
  const nestedPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8051
- return ReactiveProxy.create(value, onChange, nestedPath);
8066
+ return ReactiveProxy.create(value, undefined, nestedPath, dispatcher);
8052
8067
  }
8053
8068
  // If the value is a function, we need to wrap it to ensure that any mutations it performs also trigger onChange
8054
8069
  if (typeof value === 'function') {
@@ -8060,7 +8075,7 @@
8060
8075
  }
8061
8076
  return function (...args) {
8062
8077
  const result = value.apply(this === receiver ? obj : this, args);
8063
- onChange(path || undefined);
8078
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
8064
8079
  return result;
8065
8080
  };
8066
8081
  }
@@ -8070,7 +8085,7 @@
8070
8085
  return function (...args) {
8071
8086
  const result = value.apply(this === receiver ? obj : this, args);
8072
8087
  if (mapMutationMethods.includes(key)) {
8073
- onChange(path || undefined);
8088
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
8074
8089
  }
8075
8090
  return result;
8076
8091
  };
@@ -8081,7 +8096,7 @@
8081
8096
  return function (...args) {
8082
8097
  const result = value.apply(this === receiver ? obj : this, args);
8083
8098
  if (setMutationMethods.includes(key)) {
8084
- onChange(path || undefined);
8099
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
8085
8100
  }
8086
8101
  return result;
8087
8102
  };
@@ -8096,7 +8111,7 @@
8096
8111
  if (oldValue !== value) {
8097
8112
  const keyStr = String(key);
8098
8113
  const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8099
- onChange(fullPath);
8114
+ ReactiveProxy.dispatch(dispatcher, fullPath);
8100
8115
  }
8101
8116
  return result;
8102
8117
  },
@@ -8104,7 +8119,7 @@
8104
8119
  const result = Reflect.deleteProperty(obj, key);
8105
8120
  const keyStr = String(key);
8106
8121
  const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8107
- onChange(fullPath);
8122
+ ReactiveProxy.dispatch(dispatcher, fullPath);
8108
8123
  return result;
8109
8124
  }
8110
8125
  });
@@ -8118,6 +8133,74 @@
8118
8133
  }
8119
8134
  return proxy;
8120
8135
  }
8136
+ /**
8137
+ * Looks up the dispatcher associated with (target, path), or installs a new one.
8138
+ * When called for a nested target during proxy walking, the enclosing dispatcher
8139
+ * is reused so a single subtree fans out changes through one notification path.
8140
+ */
8141
+ static resolveDispatcher(target, path, inheritedDispatcher) {
8142
+ let pathMap = this.dispatchers.get(target);
8143
+ if (!pathMap) {
8144
+ pathMap = new Map();
8145
+ this.dispatchers.set(target, pathMap);
8146
+ }
8147
+ const existing = pathMap.get(path);
8148
+ if (existing) {
8149
+ return existing;
8150
+ }
8151
+ const dispatcher = inheritedDispatcher ?? { listeners: new Set() };
8152
+ pathMap.set(path, dispatcher);
8153
+ return dispatcher;
8154
+ }
8155
+ /**
8156
+ * Invokes every listener attached to the dispatcher.
8157
+ * Iterates a snapshot of the listener set so unsubscribing during dispatch is safe.
8158
+ */
8159
+ static dispatch(dispatcher, changedPath) {
8160
+ if (dispatcher.listeners.size === 0) {
8161
+ return;
8162
+ }
8163
+ const snapshot = Array.from(dispatcher.listeners);
8164
+ for (const listener of snapshot) {
8165
+ listener(changedPath);
8166
+ }
8167
+ }
8168
+ /**
8169
+ * Subscribes a listener to changes inside the subtree of an existing reactive proxy.
8170
+ *
8171
+ * The listener is scoped by the proxy's source path: only changes at or below that
8172
+ * path are delivered, which lets a child component receive notifications when the
8173
+ * nested contents of a prop change even though the prop reference itself is unchanged.
8174
+ *
8175
+ * @param proxyOrTarget A proxy returned from create(), or the underlying target object.
8176
+ * @param listener Called with the full source path of every relevant change.
8177
+ * @returns A function that removes the subscription.
8178
+ */
8179
+ static subscribe(proxyOrTarget, listener) {
8180
+ if (typeof proxyOrTarget !== 'object' || proxyOrTarget === null) {
8181
+ return () => { };
8182
+ }
8183
+ const target = (this.proxyToTarget.get(proxyOrTarget) ?? proxyOrTarget);
8184
+ const scopePath = this.proxyPaths.get(proxyOrTarget) ?? '';
8185
+ const pathMap = this.dispatchers.get(target);
8186
+ const dispatcher = pathMap?.get(scopePath);
8187
+ if (!dispatcher) {
8188
+ return () => { };
8189
+ }
8190
+ const wrapper = scopePath
8191
+ ? (changedPath) => {
8192
+ if (changedPath === scopePath ||
8193
+ (typeof changedPath === 'string' && (changedPath.startsWith(scopePath + '.') ||
8194
+ changedPath.startsWith(scopePath + '[')))) {
8195
+ listener(changedPath);
8196
+ }
8197
+ }
8198
+ : listener;
8199
+ dispatcher.listeners.add(wrapper);
8200
+ return () => {
8201
+ dispatcher.listeners.delete(wrapper);
8202
+ };
8203
+ }
8121
8204
  /**
8122
8205
  * Checks if the given object is a reactive proxy.
8123
8206
  *
@@ -8308,6 +8391,14 @@
8308
8391
  * is updated by the recompute cycle through `setSilent`, which bypasses this routing.
8309
8392
  */
8310
8393
  #writableComputeds = new Map();
8394
+ /**
8395
+ * Unsubscribe handles for external reactive proxies received as binding values
8396
+ * (typically component props). Each entry keeps the child bindings notified when
8397
+ * nested properties of a shared object change even though the prop reference itself
8398
+ * stays the same. The previous subscription is released whenever the binding value
8399
+ * is replaced, the binding is set to null/undefined, or the bindings are destroyed.
8400
+ */
8401
+ #externalSubscriptions = new Map();
8311
8402
  /**
8312
8403
  * Creates a new instance of VBindings.
8313
8404
  * @param parent The parent bindings, if any.
@@ -8345,6 +8436,7 @@
8345
8436
  }
8346
8437
  }
8347
8438
  let newValue = value;
8439
+ let receivedExternalProxy = false;
8348
8440
  if (typeof value === 'object' && value !== null) {
8349
8441
  // Check if the value already has a path (it's an existing reactive proxy reference)
8350
8442
  const existingPath = ReactiveProxy.getPath(value);
@@ -8354,6 +8446,7 @@
8354
8446
  this.#logger?.debug(`Path alias registered: ${key} -> ${existingPath}`);
8355
8447
  // Keep the existing proxy as-is to preserve reactivity chain
8356
8448
  newValue = value;
8449
+ receivedExternalProxy = true;
8357
8450
  }
8358
8451
  else {
8359
8452
  // Before wrapping, check if any properties are existing ReactiveProxies
@@ -8397,6 +8490,34 @@
8397
8490
  }
8398
8491
  const oldValue = Reflect.get(target, key);
8399
8492
  const result = Reflect.set(target, key, newValue);
8493
+ // Manage external subscription to a reactive proxy received as the value.
8494
+ // When the reference changes, drop the previous subscription. When a new
8495
+ // reactive proxy is installed, subscribe so nested changes propagate to
8496
+ // this bindings instance even though the proxy reference itself is stable.
8497
+ if (oldValue !== newValue) {
8498
+ const prevUnsubscribe = this.#externalSubscriptions.get(key);
8499
+ if (prevUnsubscribe) {
8500
+ prevUnsubscribe();
8501
+ this.#externalSubscriptions.delete(key);
8502
+ }
8503
+ }
8504
+ if (receivedExternalProxy && !this.#externalSubscriptions.has(key)) {
8505
+ const unsubscribe = ReactiveProxy.subscribe(newValue, (changedPath) => {
8506
+ if (!changedPath) {
8507
+ return;
8508
+ }
8509
+ let path = '';
8510
+ for (const part of changedPath.split('.')) {
8511
+ path = path ? `${path}.${part}` : part;
8512
+ this.#logger?.debug(`Binding changed (external): ${path}`);
8513
+ this.#changes.add(path);
8514
+ }
8515
+ if (!this.#suppressOnChange) {
8516
+ this.#onChange?.(changedPath);
8517
+ }
8518
+ });
8519
+ this.#externalSubscriptions.set(key, unsubscribe);
8520
+ }
8400
8521
  // Detect changes
8401
8522
  let hasChanged = oldValue !== newValue;
8402
8523
  // Special handling for arrays: check length changes even if same object reference
@@ -8425,6 +8546,11 @@
8425
8546
  },
8426
8547
  deleteProperty: (obj, key) => {
8427
8548
  const result = Reflect.deleteProperty(obj, key);
8549
+ const prevUnsubscribe = this.#externalSubscriptions.get(key);
8550
+ if (prevUnsubscribe) {
8551
+ prevUnsubscribe();
8552
+ this.#externalSubscriptions.delete(key);
8553
+ }
8428
8554
  this.#logger?.debug(`Binding deleted: ${key}`);
8429
8555
  this.#changes.add(key);
8430
8556
  this.#onChange?.(key);
@@ -8506,6 +8632,18 @@
8506
8632
  remove(key) {
8507
8633
  delete this.#local[key];
8508
8634
  }
8635
+ /**
8636
+ * Releases all external proxy subscriptions held by these bindings.
8637
+ * Should be called when the owning application is unmounted so the parent
8638
+ * application's reactive objects do not keep references to listener closures
8639
+ * (and through them, this bindings instance) alive.
8640
+ */
8641
+ destroy() {
8642
+ for (const unsubscribe of this.#externalSubscriptions.values()) {
8643
+ unsubscribe();
8644
+ }
8645
+ this.#externalSubscriptions.clear();
8646
+ }
8509
8647
  /**
8510
8648
  * Sets a binding value without triggering onChange callback.
8511
8649
  * This is useful for internal updates that shouldn't trigger reactivity.
@@ -10132,9 +10270,18 @@
10132
10270
  #sourceName;
10133
10271
  #useOfSyntax = false; // Track if 'of' syntax was used
10134
10272
  /**
10135
- * Map to track rendered items by their keys
10273
+ * Ordered list of currently rendered items.
10274
+ *
10275
+ * This is intentionally an ordered array of { key, vNode } entries rather
10276
+ * than a Map<key, VNode>. The :key attribute is a *reconciliation hint*
10277
+ * used to identify and reuse the same logical row across re-renders — it is
10278
+ * not the identity of the rendered set. Keying the rendered set by a Map
10279
+ * would make it structurally impossible to hold two rows that resolve to
10280
+ * the same key, which would silently drop application data. The most
10281
+ * fundamental invariant of a list directive is "N items in => N rows out",
10282
+ * so the rendered set must be a positional list that can carry duplicates.
10136
10283
  */
10137
- #renderedItems = new Map();
10284
+ #renderedItems = [];
10138
10285
  /**
10139
10286
  * Previous iterations to detect changes
10140
10287
  */
@@ -10257,11 +10404,11 @@
10257
10404
  destroy() {
10258
10405
  // Clean up all rendered items
10259
10406
  // First destroy all VNodes (calls @unmount hooks), then remove from DOM
10260
- for (const vNode of this.#renderedItems.values()) {
10407
+ for (const { vNode } of this.#renderedItems) {
10261
10408
  vNode.destroy();
10262
10409
  }
10263
10410
  // Then remove DOM nodes
10264
- for (const vNode of this.#renderedItems.values()) {
10411
+ for (const { vNode } of this.#renderedItems) {
10265
10412
  const range = vNode.fragmentRange;
10266
10413
  if (range) {
10267
10414
  range.remove();
@@ -10272,7 +10419,7 @@
10272
10419
  vNode.node.parentNode.removeChild(vNode.node);
10273
10420
  }
10274
10421
  }
10275
- this.#renderedItems.clear();
10422
+ this.#renderedItems = [];
10276
10423
  this.#previousIterations = [];
10277
10424
  }
10278
10425
  /**
@@ -10313,7 +10460,24 @@
10313
10460
  this.#previousIterations = iterations;
10314
10461
  }
10315
10462
  /**
10316
- * Key-based diffing for efficient DOM updates
10463
+ * Key-based diffing for efficient DOM updates.
10464
+ *
10465
+ * Reconciliation model
10466
+ * --------------------
10467
+ * The :key attribute is treated as a *hint* for reusing the same logical
10468
+ * row across re-renders, not as the identity of the rendered set. The
10469
+ * previously rendered rows are placed into a pool keyed by :key (a queue
10470
+ * per key, so equal keys can hold more than one row). Each incoming
10471
+ * iteration then claims a row from its key's queue when one is available,
10472
+ * otherwise a fresh row is created. Whatever stays in the pool at the end
10473
+ * is genuinely gone and is destroyed and removed.
10474
+ *
10475
+ * This guarantees the directive's most fundamental invariant — "N items in
10476
+ * => N rows out" — even when the application supplies duplicate keys. We
10477
+ * still warn on duplicates because they make reuse ambiguous (reordering
10478
+ * becomes positional rather than identity-stable), but we never silently
10479
+ * drop the application's data, and no row is ever orphaned: every old row
10480
+ * is either reused or explicitly removed.
10317
10481
  */
10318
10482
  #updateList(newIterations) {
10319
10483
  const parent = this.#vNode.anchorNode?.parentNode;
@@ -10321,22 +10485,38 @@
10321
10485
  if (!parent || !anchor) {
10322
10486
  throw new Error('v-for element must have a parent and anchor');
10323
10487
  }
10324
- const newRenderedItems = new Map();
10325
- // Track which keys are still needed and detect duplicates
10326
- const neededKeys = new Set();
10488
+ // Build a reuse pool from the currently rendered rows. A queue per key
10489
+ // (FIFO) lets duplicate keys reuse multiple rows: the first incoming
10490
+ // occurrence claims the first existing row, the second claims the next,
10491
+ // and so on.
10492
+ const pool = new Map();
10493
+ for (const { key, vNode } of this.#renderedItems) {
10494
+ let queue = pool.get(key);
10495
+ if (!queue) {
10496
+ queue = [];
10497
+ pool.set(key, queue);
10498
+ }
10499
+ queue.push(vNode);
10500
+ }
10501
+ // Decide, for each incoming iteration in order, whether it reuses an
10502
+ // existing row or needs a new one. Reused rows are taken out of the
10503
+ // pool so that what remains afterwards is exactly the set to remove.
10327
10504
  const seenKeys = new Set();
10328
- for (const ctx of newIterations) {
10329
- if (seenKeys.has(ctx.key)) {
10330
- console.warn(`[ichigo.js] Duplicate key detected in v-for: "${ctx.key}". This may cause unexpected behavior. Keys should be unique.`);
10505
+ const plan = [];
10506
+ for (const context of newIterations) {
10507
+ if (seenKeys.has(context.key)) {
10508
+ 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.`);
10331
10509
  }
10332
- seenKeys.add(ctx.key);
10333
- neededKeys.add(ctx.key);
10510
+ seenKeys.add(context.key);
10511
+ const queue = pool.get(context.key);
10512
+ const reused = queue && queue.length ? queue.shift() : undefined;
10513
+ plan.push({ context, reused });
10334
10514
  }
10335
- // Remove items that are no longer needed
10515
+ // Remove rows that were not reused.
10336
10516
  // First destroy VNodes (calls @unmount hooks while DOM is still accessible)
10337
10517
  const nodesToRemove = [];
10338
- for (const [key, vNode] of this.#renderedItems) {
10339
- if (!neededKeys.has(key)) {
10518
+ for (const queue of pool.values()) {
10519
+ for (const vNode of queue) {
10340
10520
  nodesToRemove.push(vNode);
10341
10521
  vNode.destroy();
10342
10522
  }
@@ -10355,11 +10535,12 @@
10355
10535
  parentOfNode.removeChild(vNode.node);
10356
10536
  }
10357
10537
  }
10358
- // Add or reorder items
10538
+ // Add or reorder rows, building the new ordered rendered set.
10539
+ const newRenderedItems = [];
10359
10540
  let prevNode = anchor;
10360
- for (const context of newIterations) {
10541
+ for (const { context, reused } of plan) {
10361
10542
  const { key } = context;
10362
- let vNode = this.#renderedItems.get(key);
10543
+ let vNode = reused;
10363
10544
  if (!vNode) {
10364
10545
  // Create new item
10365
10546
  const clone = this.#cloneNode();
@@ -10391,7 +10572,7 @@
10391
10572
  if (clone.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
10392
10573
  const range = VFragmentRange.insert(parent, prevNode.nextSibling, 'vfor-fragment', clone);
10393
10574
  vNode.fragmentRange = range;
10394
- newRenderedItems.set(key, vNode);
10575
+ newRenderedItems.push({ key, vNode });
10395
10576
  vNode.forceUpdate();
10396
10577
  prevNode = range.lastNode;
10397
10578
  continue;
@@ -10405,12 +10586,12 @@
10405
10586
  else {
10406
10587
  parent.appendChild(nodeToInsert);
10407
10588
  }
10408
- newRenderedItems.set(key, vNode);
10589
+ newRenderedItems.push({ key, vNode });
10409
10590
  vNode.forceUpdate();
10410
10591
  }
10411
10592
  else {
10412
10593
  // Reuse existing item
10413
- newRenderedItems.set(key, vNode);
10594
+ newRenderedItems.push({ key, vNode });
10414
10595
  // Update bindings
10415
10596
  this.#updateItemBindings(vNode, context);
10416
10597
  // For fragment-backed iterations, move the entire range atomically.
@@ -10436,7 +10617,7 @@
10436
10617
  // Advance prevNode to this iteration's last DOM node
10437
10618
  prevNode = vNode.fragmentRange?.lastNode ?? vNode.anchorNode ?? vNode.node;
10438
10619
  }
10439
- // Update rendered items map
10620
+ // Update the ordered rendered set
10440
10621
  this.#renderedItems = newRenderedItems;
10441
10622
  }
10442
10623
  /**
@@ -13580,6 +13761,7 @@
13580
13761
  this.#vNode.destroy();
13581
13762
  this.#vNode = undefined;
13582
13763
  }
13764
+ this.#bindings?.destroy();
13583
13765
  this.#logger.info('Application unmounted.');
13584
13766
  }
13585
13767
  /**