@ape-egg/vibe 1.9.6 → 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,28 @@
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
+
3
26
  ## [1.9.6] - 2026-05-25
4
27
 
5
28
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.6",
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
  }
@@ -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
 
@@ -996,19 +996,32 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
996
996
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
997
997
 
998
998
  // Use instances (what's actually rendered) as ground truth for old array
999
- // when oldState disagrees with the rendered count, OR when stateOldArray
1000
- // and newArray are the same reference. The latter happens for
1001
- // registry-backed iterations: when the iteration's arrayPath resolves to
1002
- // `window.__vibeIterProps._pN`, refreshIterationComponentProps updates the
1003
- // slot in place, so both reads return the same NEW array. Without this
1004
- // fallback the diff would compare the new array against itself and report
1005
- // no changes leaving the inlined iteration frozen on previously rendered
1006
- // 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.
1007
1017
  const instances = iterationNode.runtime.instances;
1008
- const oldArray =
1009
- stateOldArray !== newArray && instances.length === stateOldArray.length
1010
- ? stateOldArray
1011
- : 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;
1012
1025
 
1013
1026
  // Compiled path: Use pre-compiled batch function when available
1014
1027
  if (compiled.canUseCompiled(iterationNode)) {
@@ -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
@@ -151,6 +180,10 @@ export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
151
180
  const node = roots[r];
152
181
  if (node && node.nodeType === 1) node.__vibeScope = scope;
153
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);
154
187
  }
155
188
  };
156
189