@ape-egg/vibe 1.9.5 → 1.9.7

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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.7] - 2026-06-01
4
+
5
+ ### Fixed
6
+
7
+ - **Conditional inside an iteration row didn't react to global-state changes** (`runtime/affected.js`, `runtime/hydrate.js`) — a `<!-- if … -->` (or `<!-- if … --><!-- else --><!-- /if -->`) living inside an `<!-- each -->` row, whose expression depended on a global rather than the loop item, stayed frozen on the branch active at mount time when the global later changed and the row's `item` was unchanged. Two compounding gaps, fixed at the right layer in two steps:
8
+ - **Branch swap re-evaluated against the wrong scope** (`affected.js`, `hydrate.js`) — `affected` correctly detected the change against the row's merged state, but the affected entry it pushed carried only the node. `hydrate` then called `updateConditional` with the cycle's top-level state, which had no `item`, so the branch eval evaluated `selected.includes(item.id)` etc. against `item === undefined`. Rows mounted on the *if* branch happened to flip to *else* (often correct by coincidence); rows mounted on the *else* branch stayed stuck. `affected.js` now attaches the same `state`/`newState` pair the change was detected against as `scopedState`/`oldScopedState` on the conditional entry — merged row state inside an iteration, the cycle's top-level state outside — and `hydrate.js`'s conditional handler uses them when present.
9
+ - **Inlined per-row components were invisible to the iteration recursion** (`affected.js`) — a `<component src>` mounted per iteration row has its parsed tree stashed on the post-process wrapper as `_vibeIterTree`, not as a child of `instance.tree`. `hydrateInlinedIterationComponents` already re-hydrates those trees, but it only runs from `updateInstance`, which only fires when the iteration's array changes. On a pure global-state change the array is unchanged, so the inlined component trees were never visited — a row-internal `<!-- if -->` *without* an else, gated on a global and living inside a per-row component (the Battle Brawlers `<brawler-menu>`/`<brawler-activity>` shape), wouldn't tear down. `affected.js`'s iteration recursion now also descends into every `_vibeIterTree` reachable from each instance's cloned nodes with the same merged scoped state, so the conditional flows through the normal affected → hydrate → `updateConditional` path and the existing `branches.else === null` handling triggers `unmountBranch`. Same path covers any other binding/conditional living inside a per-row component, so they all now react to global state changes too.
10
+ - Test: `tests/e2e/conditional-in-iteration-global.spec.js` (7 tests: swap of `selected.includes(item.id)` in both directions; dynamic property lookup; compound gate with both halves; `if`-without-else direct shape; compound `if`-without-else; nested-property gate `combat.duration !== 0 && busyMap[item.id]`; and the per-row-component repro toggling `$.showMenu` through a mount→teardown→re-mount cycle)
11
+
12
+ - **Loop-scoped `on*` handler inside a row-internal `<!-- if -->` resolved the previous item after the row updated** (`runtime/loop-scope.js`) — a `<!-- if -->` inside an iteration mounts its branch content separately from the row's `clonedNodes` and stamps `__vibeScope` once at mount time. When the row was later updated in place (its DOM reused for a new item), the iteration refreshed its own root stamp but the branch root kept the stale mount-time stamp, and `resolveScope` hit that first on the walk up — so a handler inside the conditional resolved the previous item. `stampInstanceScopes` now walks the instance's parsed tree and re-stamps every active conditional-branch root with the same fresh scope. Nested loops are skipped — each iteration node already manages its own instances' scopes.
13
+ - Test: `tests/e2e/loop-scoped-handlers.spec.js` (+ unit coverage in `tests/unit/loop-scope.test.js`)
14
+
15
+ - **Treeless (batch-rendered) iteration was rebuilt on every unrelated state change** (`runtime/iterate.js`) — `affected.js` conservatively flags a treeless iteration on any state change (it can't walk per-row trees to know what they depend on), and `updateIteration` responded with an unconditional `bulkReplace`. A background tick (e.g. a 250 ms client clock) therefore recreated every row on every flush, dropping imperatively-attached listeners (tooltip mouseleave) and any in-progress drag. `renderBatch` now remembers the rendered HTML on the iteration's runtime; before tearing down, `updateIteration` re-runs the batch — if the output is byte-identical to the previous one, the rows don't depend on what changed, and the existing DOM nodes are kept. Skipped when the template has DOM-property writes (`value`/`checked`/`selected`), which aren't reflected in the HTML string.
16
+ - Test: `tests/e2e/iteration-treeless-unrelated-update.spec.js`
17
+
18
+ - **Inlined component's literal-wrap `<!-- each [prop] as alias -->` froze on the original snapshot** (`runtime/iterate.js`) — when an inlined component received an object prop and wrapped it in a literal-array iteration to render its fields, the inner iteration's `forceRegistryBackedIterationUpdates` calls `updateIteration` with `oldState === newState`, but the old/new array eval produces fresh arrays containing the just-rewritten registry value, so the diff saw identical contents and emitted no UPDATE. `updateIteration` now treats `oldState === newState` as a "trust the rendered snapshot" signal — the previously rendered items on iteration instances are the only honest "old" when the registry side-effect is the only mutation — and correctly emits per-row updates.
19
+
20
+ ### Added
21
+
22
+ - **`<component src>` callsite receives its componentId** (`runtime/component.js`) — `processSingle` now returns the resolved `componentId` so `const id = component(state)` inside a `<script type="module">` block of a src-fetched component resolves to a non-undefined id. Matches the public component.js contract; without it, downstream `$[id]` was silently undefined for src-fetched components.
23
+
24
+ ---
25
+
26
+ ## [1.9.6] - 2026-05-25
27
+
28
+ ### Fixed
29
+
30
+ - **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:
31
+ - **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.
32
+ - **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).
33
+ - 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)
34
+
35
+ ---
36
+
3
37
  ## [1.9.5] - 2026-05-23
4
38
 
5
39
  ### 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.7",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -38,6 +38,53 @@ const matchesKey = (matchStr, key) => {
38
38
  return false;
39
39
  };
40
40
 
41
+ // Resolve a clone-list entry to its live counterpart. processComponent swaps
42
+ // the original `<component src>` for a post-process `<component>` wrapper and
43
+ // records the new node on the original via `_vibeReplacedBy`. Mirrors
44
+ // `liveCloneNode` in iterate.js — kept inline to avoid a circular import.
45
+ const liveCloneNode = (node) => {
46
+ let cur = node;
47
+ while (cur && cur._vibeReplacedBy) cur = cur._vibeReplacedBy;
48
+ return cur;
49
+ };
50
+
51
+ // Descend into any inlined `<component>` parsed trees attached to an
52
+ // iteration row. `_vibeIterTree` is stamped on the post-process wrapper by
53
+ // index.js (mutation-observer path) and on nested inlined components reached
54
+ // via `[data-vibe-iter-prop]`. The trees retain the original `@[…]` binding
55
+ // text so this affected walk can flag changes the same way it would on the
56
+ // row's own tree.
57
+ const walkInlinedComponentTrees = (
58
+ clonedNodes,
59
+ state,
60
+ newState,
61
+ affected,
62
+ depth,
63
+ ) => {
64
+ for (let n = 0; n < clonedNodes.length; n++) {
65
+ const node = liveCloneNode(clonedNodes[n]);
66
+ if (!node || node.nodeType !== 1) continue;
67
+ const wrappers = [];
68
+ if (node._vibeIterTree) wrappers.push(node);
69
+ const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
70
+ if (found) {
71
+ for (let i = 0; i < found.length; i++) {
72
+ if (found[i]._vibeIterTree) wrappers.push(found[i]);
73
+ }
74
+ }
75
+ for (let w = 0; w < wrappers.length; w++) {
76
+ recursive(
77
+ wrappers[w]._vibeIterTree,
78
+ state,
79
+ newState,
80
+ affected,
81
+ newState,
82
+ depth,
83
+ );
84
+ }
85
+ }
86
+ };
87
+
41
88
  const recursive = (
42
89
  tree,
43
90
  state,
@@ -129,6 +176,26 @@ const recursive = (
129
176
  mergedNewState,
130
177
  depth + 1,
131
178
  );
179
+
180
+ // Components mounted as `<component src>` *inside* an iteration row
181
+ // are not children of `instance.tree` — their parsed tree lives on
182
+ // the post-process wrapper as `_vibeIterTree`. Without descending
183
+ // into it, conditionals / bindings inside the inlined component are
184
+ // invisible to this walk, so a global state change that should tear
185
+ // down a row-internal `<!-- if -->` (or flip a binding) inside the
186
+ // component is missed. `updateInstance` re-hydrates these trees via
187
+ // `hydrateInlinedIterationComponents`, but only when the iteration
188
+ // itself is flagged affected (array change). For pure global-state
189
+ // updates the array is unchanged, so we descend here instead.
190
+ if (instance.clonedNodes) {
191
+ walkInlinedComponentTrees(
192
+ instance.clonedNodes,
193
+ mergedOldState,
194
+ mergedNewState,
195
+ affected,
196
+ depth + 1,
197
+ );
198
+ }
132
199
  }
133
200
  }
134
201
  }
@@ -151,10 +218,19 @@ const recursive = (
151
218
 
152
219
  // Check if condition result changed
153
220
  if (oldValue !== newValue) {
221
+ // Carry the state pair the condition was evaluated against. Inside an
222
+ // iteration row this is the merged state (loop alias + current globals),
223
+ // outside it is the cycle's top-level state. hydrate hands these to
224
+ // updateConditional so the branch swap re-evaluates the expression in
225
+ // the same scope — without them, a row-internal `if` is re-evaluated
226
+ // against a state with no `item`, which always reads falsy and leaves
227
+ // rows that were initially on the else branch stuck there.
154
228
  affected.push({
155
229
  type: "conditional",
156
230
  node: tree,
157
231
  changeType: "expression",
232
+ scopedState: newState,
233
+ oldScopedState: state,
158
234
  });
159
235
  return affected;
160
236
  }
@@ -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
@@ -14,9 +14,15 @@ export default (affected, state, manifest = {}, oldState = {}) => {
14
14
  return;
15
15
  }
16
16
 
17
- // Handle conditional updates
17
+ // Handle conditional updates. Use the scoped pair affected attached when
18
+ // the conditional sits inside an iteration row — that pair has the row's
19
+ // loop alias overlaid on current globals, so the branch eval re-runs in
20
+ // the same scope the change was detected against. Top-level conditionals
21
+ // pass through with the cycle's state (affected attaches it verbatim there).
18
22
  if (aff.type === 'conditional') {
19
- updateConditional(aff.node, state, oldState, manifest);
23
+ const condNewState = aff.scopedState || state;
24
+ const condOldState = aff.oldScopedState || oldState;
25
+ updateConditional(aff.node, condNewState, condOldState, manifest);
20
26
  return;
21
27
  }
22
28
 
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
@@ -948,19 +996,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
948
996
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
949
997
 
950
998
  // Use instances (what's actually rendered) as ground truth for old array
951
- // when oldState disagrees with the rendered count, OR when stateOldArray
952
- // and newArray are the same reference. The latter happens for
953
- // registry-backed iterations: when the iteration's arrayPath resolves to
954
- // `window.__vibeIterProps._pN`, refreshIterationComponentProps updates the
955
- // slot in place, so both reads return the same NEW array. Without this
956
- // fallback the diff would compare the new array against itself and report
957
- // no changes leaving the inlined iteration frozen on previously rendered
958
- // items.
999
+ // whenever the only signal of change is a registry side-effect, or when
1000
+ // the rendered count doesn't match the freshly evaluated state. Three
1001
+ // cases collapse to "trust the rendered snapshot":
1002
+ // 1. oldState === newState — forceRegistryBackedIterationUpdates calls
1003
+ // updateIteration with the same state on both sides because the only
1004
+ // mutation was a registry slot rewrite. The previously rendered
1005
+ // items are the only honest record of what was there before.
1006
+ // 2. stateOldArray === newArray — the iteration's arrayPath resolves
1007
+ // directly to a registry slot (`window.__vibeIterProps._pN`); the
1008
+ // slot was swapped in place, so both reads return the same NEW
1009
+ // array.
1010
+ // 3. length mismatch — oldState predates the current render.
1011
+ // Otherwise the freshly evaluated state is a trustworthy "old".
1012
+ //
1013
+ // Literal-wrap expressions like `[s]` would otherwise slip through this
1014
+ // net: they build different array refs each eval but both contain the
1015
+ // just-rewritten registry value, so a naive diff sees no change. Case 1
1016
+ // catches them.
959
1017
  const instances = iterationNode.runtime.instances;
960
- const oldArray =
961
- stateOldArray !== newArray && instances.length === stateOldArray.length
962
- ? stateOldArray
963
- : instances.map((inst) => inst.item);
1018
+ const useInstancesAsOld =
1019
+ oldState === newState ||
1020
+ stateOldArray === newArray ||
1021
+ instances.length !== stateOldArray.length;
1022
+ const oldArray = useInstancesAsOld
1023
+ ? instances.map((inst) => inst.item)
1024
+ : stateOldArray;
964
1025
 
965
1026
  // Compiled path: Use pre-compiled batch function when available
966
1027
  if (compiled.canUseCompiled(iterationNode)) {
@@ -1071,7 +1132,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1071
1132
 
1072
1133
  // Re-stamp after the diff settles: moved/updated instances now carry their
1073
1134
  // current item + index, so `$scope` handlers resolve correctly post-reorder.
1074
- stampInstanceScopes(iterationNode, parentScope);
1135
+ stampScopes(iterationNode, manifest, parentScope);
1075
1136
  };
1076
1137
 
1077
1138
  // Bulk replacement: clear all DOM and re-render from scratch
@@ -1096,7 +1157,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1096
1157
  // For simple templates (no nested iterations/conditionals, single root element),
1097
1158
  // use batch string rendering: one string concatenation loop + one innerHTML parse
1098
1159
  if (canUseBatchRender(template)) {
1099
- renderBatch(iterationNode, newArray, state, parent, endComment, parentScope);
1160
+ renderBatch(iterationNode, newArray, state, parent, endComment, parentScope, manifest);
1100
1161
  const instances = iterationNode.runtime.instances;
1101
1162
  for (let i = 0; i < instances.length; i++) {
1102
1163
  if (instances[i].element?.nodeType === 1) managedNodes.add(instances[i].element);
@@ -1109,12 +1170,13 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1109
1170
  // parent.insertBefore call.
1110
1171
  const instances = [];
1111
1172
  const frag = document.createDocumentFragment();
1173
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
1112
1174
  for (let i = 0; i < newArray.length; i++) {
1113
- const built = buildInstance(iterationNode, newArray[i], i, state, parentScope);
1175
+ const built = buildInstance(iterationNode, newArray[i], i, state, parentScope, liveItemAt(liveArray, i, newArray[i]));
1114
1176
  for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
1115
1177
  finalizeInstance(built, manifest, parentScope);
1116
1178
  instances.push({
1117
- element: built.element, tree: built.tree, item: newArray[i], index: i,
1179
+ element: built.element, tree: built.tree, item: newArray[i], liveItem: built.liveItem, index: i,
1118
1180
  clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1119
1181
  });
1120
1182
  }
@@ -1122,7 +1184,7 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1122
1184
  iterationNode.runtime.instances = instances;
1123
1185
 
1124
1186
  // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1125
- stampInstanceScopes(iterationNode, parentScope);
1187
+ stampScopes(iterationNode, manifest, parentScope);
1126
1188
  };
1127
1189
 
1128
1190
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -1191,14 +1253,19 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1191
1253
  // Build a fresh instance's DOM + tree + scope from the iteration template.
1192
1254
  // Pure function — no DOM insertion, no side effects on iteration state.
1193
1255
  // Callers decide where the clones go (iteration parent, DocumentFragment).
1194
- const buildInstance = (iterationNode, item, index, state, parentScope) => {
1256
+ // `liveItem` (resolved by the caller via resolveLiveArray, so a derived-array
1257
+ // loop evaluates the expression once per render, not once per row) is the live
1258
+ // `$`-proxy element for this index. Building scope from it gives nested
1259
+ // conditional stamps and `$scope` handlers the app-visible identity; `item`
1260
+ // (plain snapshot) is still tracked for the diff.
1261
+ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem) => {
1195
1262
  const { itemAlias, indexAlias, template, startComment } = iterationNode.meta;
1196
- const localVars = { [itemAlias]: item, [indexAlias]: index };
1263
+ const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
1197
1264
  const scopedState = createScopedState(state, localVars, parentScope);
1198
1265
  const componentId = findComponentIdForElement(startComment.parentElement);
1199
1266
  const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
1200
1267
  resolveIterationComponentProps(built.clonedNodes, scopedState);
1201
- return { ...built, scopedState, localVars };
1268
+ return { ...built, scopedState, localVars, liveItem };
1202
1269
  };
1203
1270
 
1204
1271
  // After a built instance's clones are placed in the DOM (directly or via a
@@ -1220,12 +1287,13 @@ const finalizeInstance = (built, manifest, parentScope) => {
1220
1287
 
1221
1288
  const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
1222
1289
  const parent = iterationNode.meta.startComment.parentNode;
1223
- const built = buildInstance(iterationNode, item, index, state, parentScope);
1290
+ const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
1291
+ const built = buildInstance(iterationNode, item, index, state, parentScope, liveItem);
1224
1292
  const insertBefore = resolveInsertBefore(iterationNode, index, parent);
1225
1293
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
1226
1294
  finalizeInstance(built, manifest, parentScope);
1227
1295
  iterationNode.runtime.instances.splice(index, 0, {
1228
- element: built.element, tree: built.tree, item, index, clonedNodes: built.clonedNodes,
1296
+ element: built.element, tree: built.tree, item, liveItem: built.liveItem, index, clonedNodes: built.clonedNodes,
1229
1297
  });
1230
1298
  };
1231
1299
 
@@ -1267,7 +1335,8 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1267
1335
  const parent = iterationNode.meta.startComment.parentNode;
1268
1336
  const instance = iterationNode.runtime.instances[index];
1269
1337
 
1270
- const built = buildInstance(iterationNode, newItem, index, state, parentScope);
1338
+ const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1339
+ const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
1271
1340
  detachInstanceDom(iterationNode, index, parent);
1272
1341
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
1273
1342
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
@@ -1276,6 +1345,7 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1276
1345
  instance.element = built.element;
1277
1346
  instance.tree = built.tree;
1278
1347
  instance.item = newItem;
1348
+ instance.liveItem = built.liveItem;
1279
1349
  instance.clonedNodes = built.clonedNodes;
1280
1350
  instance.scopedState = built.scopedState;
1281
1351
  };
@@ -128,6 +128,35 @@ export const resolveScope = (el, name) => {
128
128
  return undefined;
129
129
  };
130
130
 
131
+ // Re-stamp the instance scope onto conditional-branch roots mounted inside a
132
+ // row. A `<!-- if -->` within a loop mounts its branch content separately from
133
+ // the iteration's own clonedNodes and stamps it once, at mount time
134
+ // (conditionals.js). When the row later updates in place — its DOM node reused
135
+ // for a new item — the iteration refreshes its own root stamp, but the branch
136
+ // root keeps the stale mount-time stamp, and `resolveScope` hits that first on
137
+ // the walk up (so a handler inside the conditional resolves the previous item).
138
+ // Walking the instance's parsed tree and re-stamping every active conditional
139
+ // branch root with the same fresh `scope` object closes that gap. Nested loops
140
+ // are skipped: each iteration node manages its own instances' scopes.
141
+ const restampConditionalBranches = (tree, scope) => {
142
+ if (!tree || typeof tree !== 'object') return;
143
+ if (tree.type === 'iteration') return;
144
+ if (tree.type === 'conditional') {
145
+ const active = tree.runtime?.activeInstance;
146
+ if (active?.nodes) {
147
+ for (let i = 0; i < active.nodes.length; i++) {
148
+ const node = active.nodes[i];
149
+ if (node && node.nodeType === 1) node.__vibeScope = scope;
150
+ }
151
+ }
152
+ if (active?.parsedTree) restampConditionalBranches(active.parsedTree, scope);
153
+ return;
154
+ }
155
+ if (tree.children) {
156
+ for (const key in tree.children) restampConditionalBranches(tree.children[key], scope);
157
+ }
158
+ };
159
+
131
160
  // Stamp the in-scope loop vars onto every iteration instance's root element
132
161
  // node(s). The stamp accumulates the enclosing loop vars (`parentScope`) plus
133
162
  // this loop's item/index, so a single innermost stamp resolves every alias in
@@ -141,12 +170,20 @@ export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
141
170
  const instances = iterationNode.runtime.instances;
142
171
  for (let k = 0; k < instances.length; k++) {
143
172
  const inst = instances[k];
144
- const scope = { ...parentScope, [itemAlias]: inst.item, [indexAlias]: inst.index };
173
+ // `liveItem` (set by stampScopes in iterate.js) is the live `$`-proxy
174
+ // element so handlers get the identity the app sees; `item` is the plain
175
+ // diff-snapshot clone used for rendering. Prefer live when available.
176
+ const item = inst.liveItem !== undefined ? inst.liveItem : inst.item;
177
+ const scope = { ...parentScope, [itemAlias]: item, [indexAlias]: inst.index };
145
178
  const roots = inst.clonedNodes || (inst.element ? [inst.element] : []);
146
179
  for (let r = 0; r < roots.length; r++) {
147
180
  const node = roots[r];
148
181
  if (node && node.nodeType === 1) node.__vibeScope = scope;
149
182
  }
183
+ // Conditional branches inside the row carry their own stamp from mount time;
184
+ // refresh them with the same fresh scope so in-place row updates don't leave
185
+ // a handler inside an `<!-- if -->` resolving the previous item.
186
+ if (inst.tree) restampConditionalBranches(inst.tree, scope);
150
187
  }
151
188
  };
152
189
 
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.