@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 +11 -0
- package/package.json +1 -1
- package/runtime/component.js +5 -0
- package/runtime/index.js +7 -0
- package/runtime/iterate.js +73 -16
- package/runtime/loop-scope.js +5 -1
- package/runtime/state.js +27 -0
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
package/runtime/component.js
CHANGED
|
@@ -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);
|
package/runtime/iterate.js
CHANGED
|
@@ -476,7 +476,48 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
|
|
|
476
476
|
}
|
|
477
477
|
};
|
|
478
478
|
|
|
479
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|
|
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
|
|
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
|
|
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
|
};
|
package/runtime/loop-scope.js
CHANGED
|
@@ -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
|
-
|
|
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.
|