@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 +34 -0
- package/package.json +1 -1
- package/runtime/affected.js +76 -0
- package/runtime/component.js +5 -0
- package/runtime/hydrate.js +8 -2
- package/runtime/index.js +7 -0
- package/runtime/iterate.js +98 -28
- package/runtime/loop-scope.js +38 -1
- package/runtime/state.js +27 -0
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
package/runtime/affected.js
CHANGED
|
@@ -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
|
}
|
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/hydrate.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
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
|
|
@@ -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
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
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
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|
|
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
|
|
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
|
|
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
|
};
|
package/runtime/loop-scope.js
CHANGED
|
@@ -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
|
-
|
|
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.
|