@ape-egg/vibe 1.9.5 → 1.9.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.6] - 2026-05-25
4
+
5
+ ### Fixed
6
+
7
+ - **Loop item lost reference identity across mutations** (`runtime/state.js`, `runtime/iterate.js`, `runtime/index.js`, `runtime/loop-scope.js`) — the object a loop handed its `on*` handlers was no longer `=== $.arr[i]` after a `splice`/`push`, so identity-based code (`$.arr.indexOf(item)`, `item === $.arr[i]`) targeted the wrong row — e.g. removing two items by identity in a row would delete the wrong second row. Two compounding causes:
8
+ - **Proxy double-wrapping** (`state.js`) — the deep proxy's `get` handed back a fresh proxy for nested elements, and array methods/assignments wrote that proxy back into the tree; the next read wrapped it *again*, minting a new proxy identity for the same underlying object (and making change detection compare a stored proxy against a raw, never equal). Added a `RAW` symbol so any of our proxies can expose its raw target, plus an `unwrap` helper used in `set` (never store a proxy in the raw tree) and at `createDeepProxy` entry (collapse a proxy that slipped in nested inside an assigned object literal). The cache now always returns the single canonical proxy per object.
9
+ - **Loop var was a diff-snapshot clone** (`iterate.js`, `index.js`, `loop-scope.js`) — iterations render against `extractPlainValue($)` plain clones, which are never reference-identical to the proxy elements the app sees through `$`. The runtime now exposes the live proxy as `manifest.__live`; `resolveLiveArray` re-resolves the loop's array against it and each instance carries a `liveItem` that `stampInstanceScopes` prefers, so `$scope(this,'alias')` handlers (and nested conditionals stamping `__vibeScope`) receive live identity. Diffing still keys off the plain `inst.item`; only the handler-facing value is live. Falls back to the plain item when the live array can't be resolved (no worse than before).
10
+ - Test: `tests/e2e/iteration-item-identity.spec.js` (handler receives the exact state-array element; identity-based removal targets the right item twice in a row across splices)
11
+
12
+ ---
13
+
3
14
  ## [1.9.5] - 2026-05-23
4
15
 
5
16
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.5",
3
+ "version": "1.9.6",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -397,6 +397,11 @@ const processSingle = (el, debug) => {
397
397
  if (!registeredComponentIds.includes(componentId)) {
398
398
  registeredComponentIds.push(componentId);
399
399
  }
400
+ // Return the id so consumers can reach their reactive state via
401
+ // `$[id]` — matches the public component.js contract. Without this,
402
+ // `const id = component(state)` is undefined for src-fetched
403
+ // components and `$[id]` silently resolves to nothing.
404
+ return componentId;
400
405
  };
401
406
 
402
407
  // Re-running the script for a reused componentId (HMR remount) must
package/runtime/index.js CHANGED
@@ -665,6 +665,13 @@ const main = (s, config = {}, stringSelector = '') => {
665
665
  enumerable: false,
666
666
  });
667
667
 
668
+ // Expose the live reactive proxy to the iteration stamper so loop-scoped
669
+ // `on*` handlers (`$scope`) resolve the SAME object identity the app sees via
670
+ // `$`, instead of the plain diff-snapshot clones iterations render against
671
+ // (see extractPlainValue below). Non-enumerable so it never shows up in the
672
+ // manifest's node-path entry iteration.
673
+ Object.defineProperty(manifest, '__live', { value: $, enumerable: false, configurable: true });
674
+
668
675
  // Initial hydration - pass plain values so iteration can do reference comparison
669
676
  const initialState = extractPlainValue($);
670
677
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -476,7 +476,48 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
476
476
  }
477
477
  };
478
478
 
479
- const renderBatch = (iterationNode, array, state, parent, endComment, parentScope = {}) => {
479
+ // Resolve the loop's array against the LIVE `$` proxy root (not the plain
480
+ // diff-snapshot the pipeline renders against — see extractPlainValue in
481
+ // index.js). The plain clones are never reference-identical to the proxy
482
+ // elements the app sees through `$`, so a loop var that flows into an `on*`
483
+ // handler must come from here for `item === $.arr[i]` to hold. Returns null
484
+ // when unresolvable (no live root yet, a derivation that builds fresh objects,
485
+ // or a nested loop whose source hangs off an outer plain item) — callers fall
486
+ // back to the plain item, which is no worse than the pre-fix behavior.
487
+ const resolveLiveArray = (iterationNode, manifest, parentScope = {}) => {
488
+ const liveRoot = (manifest && manifest.__live) || globalThis.$;
489
+ if (!liveRoot) return null;
490
+ try {
491
+ const { arrayPath, startComment } = iterationNode.meta;
492
+ const parentEl = startComment.parentElement;
493
+ const resolved = resolveThisPath(arrayPath, parentEl);
494
+ const liveScoped = createScopedState(liveRoot, {}, parentScope);
495
+ const a = evalInScope(resolved, liveScoped, parentEl) ?? resolvePath(liveScoped, resolved);
496
+ return Array.isArray(a) ? a : null;
497
+ } catch {
498
+ return null;
499
+ }
500
+ };
501
+
502
+ const liveItemAt = (liveArray, index, fallback) =>
503
+ liveArray && index < liveArray.length ? liveArray[index] : fallback;
504
+
505
+ // Refresh each instance's `liveItem` (the live `$`-proxy element handed to
506
+ // loop-scoped `$scope` handlers, set when the instance was built) and stamp
507
+ // scope. Re-resolving here keeps `liveItem` correct after the diff reorders or
508
+ // updates instances. Diffing still keys off the plain `inst.item`; only the
509
+ // handler-facing `$scope` value is live.
510
+ const stampScopes = (iterationNode, manifest, parentScope = {}) => {
511
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
512
+ const instances = iterationNode.runtime.instances;
513
+ for (let k = 0; k < instances.length; k++) {
514
+ const inst = instances[k];
515
+ inst.liveItem = liveItemAt(liveArray, inst.index, inst.item);
516
+ }
517
+ stampInstanceScopes(iterationNode, parentScope);
518
+ };
519
+
520
+ const renderBatch = (iterationNode, array, state, parent, endComment, parentScope = {}, manifest) => {
480
521
  const { itemAlias, indexAlias, template } = iterationNode.meta;
481
522
 
482
523
  if (!iterationNode.runtime.batchFn) {
@@ -512,7 +553,7 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
512
553
  }
513
554
 
514
555
  // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
515
- stampInstanceScopes(iterationNode, parentScope);
556
+ stampScopes(iterationNode, manifest, parentScope);
516
557
  };
517
558
 
518
559
  /**
@@ -866,9 +907,16 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
866
907
  // detached during hydrate (see findComponentIdForElement's detached fallback).
867
908
  const componentId = findComponentIdForElement(startComment.parentElement);
868
909
 
910
+ // Live `$`-proxy elements for the loop alias, so nested conditionals (which
911
+ // stamp `__vibeScope` from this scoped state) and `$scope` handlers receive
912
+ // the same identity the app sees through `$`. `inst.item` stays the plain
913
+ // snapshot value for the diff.
914
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
915
+
869
916
  for (let i = 0; i < array.length; i++) {
870
917
  const item = array[i];
871
- const localVars = { [itemAlias]: item, [indexAlias]: i };
918
+ const liveItem = liveItemAt(liveArray, i, item);
919
+ const localVars = { [itemAlias]: liveItem, [indexAlias]: i };
872
920
  const scopedState = createScopedState(state, localVars, parentScope);
873
921
 
874
922
  // Clone, parse, hydrate
@@ -890,7 +938,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
890
938
  _renderAllConditionals(tree, scopedState, manifest, nestedScope);
891
939
  }
892
940
 
893
- instances.push({ element, tree, item, index: i, clonedNodes, scopedState });
941
+ instances.push({ element, tree, item, liveItem, index: i, clonedNodes, scopedState });
894
942
  }
895
943
 
896
944
  // Single DOM insertion for all items
@@ -898,7 +946,7 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
898
946
  iterationNode.runtime.instances = instances;
899
947
 
900
948
  // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
901
- stampInstanceScopes(iterationNode, parentScope);
949
+ stampScopes(iterationNode, manifest, parentScope);
902
950
 
903
951
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
904
952
  // Also store runtime data on the DOM node so it persists across re-parses
@@ -1071,7 +1119,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1071
1119
 
1072
1120
  // Re-stamp after the diff settles: moved/updated instances now carry their
1073
1121
  // current item + index, so `$scope` handlers resolve correctly post-reorder.
1074
- stampInstanceScopes(iterationNode, parentScope);
1122
+ stampScopes(iterationNode, manifest, parentScope);
1075
1123
  };
1076
1124
 
1077
1125
  // Bulk replacement: clear all DOM and re-render from scratch
@@ -1096,7 +1144,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1096
1144
  // For simple templates (no nested iterations/conditionals, single root element),
1097
1145
  // use batch string rendering: one string concatenation loop + one innerHTML parse
1098
1146
  if (canUseBatchRender(template)) {
1099
- renderBatch(iterationNode, newArray, state, parent, endComment, parentScope);
1147
+ renderBatch(iterationNode, newArray, state, parent, endComment, parentScope, manifest);
1100
1148
  const instances = iterationNode.runtime.instances;
1101
1149
  for (let i = 0; i < instances.length; i++) {
1102
1150
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -1109,12 +1157,13 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1109
1157
  // parent.insertBefore call.
1110
1158
  const instances = [];
1111
1159
  const frag = document.createDocumentFragment();
1160
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
1112
1161
  for (let i = 0; i < newArray.length; i++) {
1113
- const built = buildInstance(iterationNode, newArray[i], i, state, parentScope);
1162
+ const built = buildInstance(iterationNode, newArray[i], i, state, parentScope, liveItemAt(liveArray, i, newArray[i]));
1114
1163
  for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
1115
1164
  finalizeInstance(built, manifest, parentScope);
1116
1165
  instances.push({
1117
- element: built.element, tree: built.tree, item: newArray[i], index: i,
1166
+ element: built.element, tree: built.tree, item: newArray[i], liveItem: built.liveItem, index: i,
1118
1167
  clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1119
1168
  });
1120
1169
  }
@@ -1122,7 +1171,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1122
1171
  iterationNode.runtime.instances = instances;
1123
1172
 
1124
1173
  // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1125
- stampInstanceScopes(iterationNode, parentScope);
1174
+ stampScopes(iterationNode, manifest, parentScope);
1126
1175
  };
1127
1176
 
1128
1177
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -1191,14 +1240,19 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1191
1240
  // Build a fresh instance's DOM + tree + scope from the iteration template.
1192
1241
  // Pure function — no DOM insertion, no side effects on iteration state.
1193
1242
  // Callers decide where the clones go (iteration parent, DocumentFragment).
1194
- const buildInstance = (iterationNode, item, index, state, parentScope) => {
1243
+ // `liveItem` (resolved by the caller via resolveLiveArray, so a derived-array
1244
+ // loop evaluates the expression once per render, not once per row) is the live
1245
+ // `$`-proxy element for this index. Building scope from it gives nested
1246
+ // conditional stamps and `$scope` handlers the app-visible identity; `item`
1247
+ // (plain snapshot) is still tracked for the diff.
1248
+ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem) => {
1195
1249
  const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
1196
- const localVars = { [itemAlias]: item, [indexAlias]: index };
1250
+ const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
1197
1251
  const scopedState = createScopedState(state, localVars, parentScope);
1198
1252
  const componentId = findComponentIdForElement(startComment.parentElement);
1199
1253
  const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
1200
1254
  resolveIterationComponentProps(built.clonedNodes, scopedState);
1201
- return { ...built, scopedState, localVars };
1255
+ return { ...built, scopedState, localVars, liveItem };
1202
1256
  };
1203
1257
 
1204
1258
  // After a built instance's clones are placed in the DOM (directly or via a
@@ -1220,12 +1274,13 @@ const finalizeInstance = (built, manifest, parentScope) => {
1220
1274
 
1221
1275
  const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
1222
1276
  const parent = iterationNode.meta.startComment.parentNode;
1223
- const built = buildInstance(iterationNode, item, index, state, parentScope);
1277
+ const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
1278
+ const built = buildInstance(iterationNode, item, index, state, parentScope, liveItem);
1224
1279
  const insertBefore = resolveInsertBefore(iterationNode, index, parent);
1225
1280
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
1226
1281
  finalizeInstance(built, manifest, parentScope);
1227
1282
  iterationNode.runtime.instances.splice(index, 0, {
1228
- element: built.element, tree: built.tree, item, index, clonedNodes: built.clonedNodes,
1283
+ element: built.element, tree: built.tree, item, liveItem: built.liveItem, index, clonedNodes: built.clonedNodes,
1229
1284
  });
1230
1285
  };
1231
1286
 
@@ -1267,7 +1322,8 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1267
1322
  const parent = iterationNode.meta.startComment.parentNode;
1268
1323
  const instance = iterationNode.runtime.instances[index];
1269
1324
 
1270
- const built = buildInstance(iterationNode, newItem, index, state, parentScope);
1325
+ const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1326
+ const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
1271
1327
  detachInstanceDom(iterationNode, index, parent);
1272
1328
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
1273
1329
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
@@ -1276,6 +1332,7 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1276
1332
  instance.element = built.element;
1277
1333
  instance.tree = built.tree;
1278
1334
  instance.item = newItem;
1335
+ instance.liveItem = built.liveItem;
1279
1336
  instance.clonedNodes = built.clonedNodes;
1280
1337
  instance.scopedState = built.scopedState;
1281
1338
  };
@@ -141,7 +141,11 @@ export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
141
141
  const instances = iterationNode.runtime.instances;
142
142
  for (let k = 0; k < instances.length; k++) {
143
143
  const inst = instances[k];
144
- const scope = { ...parentScope, [itemAlias]: inst.item, [indexAlias]: inst.index };
144
+ // `liveItem` (set by stampScopes in iterate.js) is the live `$`-proxy
145
+ // element so handlers get the identity the app sees; `item` is the plain
146
+ // diff-snapshot clone used for rendering. Prefer live when available.
147
+ const item = inst.liveItem !== undefined ? inst.liveItem : inst.item;
148
+ const scope = { ...parentScope, [itemAlias]: item, [indexAlias]: inst.index };
145
149
  const roots = inst.clonedNodes || (inst.element ? [inst.element] : []);
146
150
  for (let r = 0; r < roots.length; r++) {
147
151
  const node = roots[r];
package/runtime/state.js CHANGED
@@ -1,6 +1,20 @@
1
1
  // Track which objects are already proxied to avoid double-wrapping
2
2
  const proxyCache = new WeakMap();
3
3
 
4
+ // Reading this symbol off any of our reactive proxies returns its raw
5
+ // (unproxied) target; on anything else it's undefined. `unwrap` uses it to
6
+ // collapse a proxy back to its raw object. This keeps object identity stable
7
+ // across mutations: array methods (splice/push) and assignments read an element
8
+ // — which `get` hands back as a proxy — and write it back into the tree; if we
9
+ // stored the proxy, the next read would wrap it AGAIN, minting a fresh proxy
10
+ // identity for the same underlying object. That would break reference identity
11
+ // (a loop item would no longer be `=== $.arr[i]` after a splice) and make change
12
+ // detection fire forever (a stored proxy never `===` the raw it's compared to).
13
+ const RAW = Symbol('vibeRaw');
14
+
15
+ const unwrap = (value) =>
16
+ value !== null && typeof value === 'object' && value[RAW] !== undefined ? value[RAW] : value;
17
+
4
18
  // Batching: collect mutations and flush once per microtask
5
19
  let pendingFlush = false;
6
20
  let flushCallback = null;
@@ -20,6 +34,12 @@ const scheduleFlush = () => {
20
34
 
21
35
  // Deep proxy: recursively wrap nested objects and arrays
22
36
  const createDeepProxy = (target, rerender, rootState = null, rootProp = null) => {
37
+ // Never wrap one of our own proxies — collapse to its raw target so the cache
38
+ // returns the single canonical proxy. Covers a proxy that slipped into the
39
+ // tree nested inside an assigned object literal (set only unwraps the
40
+ // top-level value), which would otherwise double-wrap on read.
41
+ target = unwrap(target);
42
+
23
43
  // For root level, rootState is the target itself
24
44
  if (rootState === null) {
25
45
  rootState = target;
@@ -33,6 +53,9 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
33
53
 
34
54
  const proxy = new Proxy(target, {
35
55
  set(obj, prop, value) {
56
+ // Never store one of our proxies in the raw tree — store its raw target,
57
+ // so element identity stays stable across mutations (see RAW comment).
58
+ value = unwrap(value);
36
59
  const oldValue = obj[prop];
37
60
 
38
61
  // Only trigger rerender if value actually changed
@@ -68,6 +91,10 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
68
91
  },
69
92
 
70
93
  get(target, prop) {
94
+ // Expose the raw target so `unwrap` (and external identity checks) can
95
+ // recover the unproxied object from any of our proxies.
96
+ if (prop === RAW) return target;
97
+
71
98
  const value = Reflect.get(target, prop);
72
99
 
73
100
  // Don't proxy non-objects, functions, null, or Promises.