@ape-egg/vibe 1.9.7 → 1.9.9

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.9.9] - 2026-06-06
4
+
5
+ ### Fixed
6
+
7
+ - **Iteration teardown leaked removed rows** (`runtime/iterate.js`, `runtime/affected.js`, `runtime/index.js`) — when an iteration's array emptied or rows were removed, Vibe deleted the DOM but kept internal references to the detached subtrees: the per-row parsed trees, inlined per-row component trees, and the flat manifest entries (`dotPath -> element`). Those structures pinned the removed nodes in memory and made every later reconcile/affected walk traverse dead nodes — in Battle Brawlers, combat fps decayed on each Reset→Start cycle. The page MutationObserver is disconnected while Vibe renders, so removal happens unobserved and the normal observer-driven cleanup never fires; teardown must prune itself. `index.js` now pairs the flat manifest with its parsed tree via a non-enumerable `manifest.__tree` (mirroring `manifest.__live`), and `iterate.js`'s new `releaseRemovedSubtrees` prunes both views together on row removal — deleting each removed root's manifest paths (path-scoped) and tree node (whose subtree cascade covers nested iterations/conditionals/components). Proven with a WeakRef + forced-GC e2e test: after emptying the iteration, zero removed elements remain reachable.
8
+ - Test: `tests/e2e/iteration-teardown-leak.spec.js` (no retained DOM after GC; repeated fill/empty cycles don't accumulate)
9
+
10
+ ### Performance
11
+
12
+ - **Scoped-state key/overlay lookups avoided proxy traps on the combat hot path** (`runtime/utils.js`, `runtime/iterate.js`, `runtime/affected.js`) — `createScopedState` now records each scoped-state proxy's precomputed key list and its small local overlay (loop aliases `item`/`index` + parent aliases) in WeakMaps via `rememberScopedKeys`. `evalInScope` reads the keys through `ownKeysOf` instead of `Object.keys(proxy)` (which fired the proxy's `getOwnPropertyDescriptor` trap for every key, every eval), and `affected`'s iteration descent rebuilds its per-instance merged snapshot via a cheap `{...currentGlobals, ...overlay}` plain merge (`scopedOverlayOf`) instead of spreading the proxy over every global key.
13
+
14
+ ---
15
+
16
+ ## [1.9.8] - 2026-06-01
17
+
18
+ ### Added
19
+
20
+ - **Raw-HTML rendering via `$.unsafe(expr)`** (`runtime/raw-html.js` (new), `runtime/hydrate.js`, `runtime/index.js`, `runtime/utils.js`, `compiler/src/compiler/value_stamper.rs`) — a binding that renders a trusted string as real markup instead of escaping it; Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`. `$.unsafe(str)` wraps a string in a `RawHtml` marker. When the binding is the **sole content of its element** (`<p>@[$.unsafe(desc)]</p>`), the runtime sets `innerHTML` from the string and marks the injected subtree opaque (`managedNodes`) so the MutationObserver never re-walks it — injected markup is **inert**, matching Svelte. Mixed into surrounding text it falls back to escaped literal text (via the marker's `toString()`). Reactive in every scope (top-level, iterations, conditionals, components). Compiled mode paints the markup raw at stamp time (`value_stamper.rs` defines `$.unsafe` in the QuickJS context) while the manifest preserves the `@[$.unsafe(...)]` marker for runtime re-hydration. **Trusted input only — no sanitizing.**
21
+ - Test: `tests/unit/raw-html.test.js`, `tests/e2e/unsafe-html.spec.js` (pure render, escaped-text fallback, reactivity, in-iteration, in-conditional, inert injected markup), and `$.unsafe` stamping in `tests/compiler/runtime`.
22
+
23
+ ### Fixed
24
+
25
+ - **`$` inside expressions must read from the per-cycle state, not the live root** (`runtime/utils.js`) — surfaced while wiring `$.unsafe`. The reactivity engine detects change by evaluating each expression against an old snapshot and a new snapshot and comparing; binding `$` to the live root proxy made both reads identical, silently defeating change detection for any expression reading through `$` or `this.` (which compiles to `$['id']…`) — e.g. a computed iteration array or conditional gated on component state would stop re-rendering. `evalInScope` now resolves `$` to the same state object the diff cycle is evaluating, and reaches the root's non-enumerable helper methods (`unsafe`, `on`, `reconcile`) via a thin per-state fallback proxy so `$.unsafe` stays callable from plain snapshots on the update path. State reads stay on the snapshot (diffable); only missing method names fall through to root. Scoped states already delegate to root, so the hot path is unaffected.
26
+ - Test: `tests/unit/dollar-binding-scope.test.js` (a `$`-expression yields different values for two snapshots → diffable; `$.unsafe` resolves from a plain snapshot lacking it)
27
+
28
+ ---
29
+
3
30
  ## [1.9.7] - 2026-06-01
4
31
 
5
32
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vibe
2
2
 
3
- **Version 1.9.2 (Beta)** — A runtime-first reactive framework with optional compilation.
3
+ **Version 1.9.9 (Beta)** — A runtime-first reactive framework with optional compilation.
4
4
 
5
5
  No virtual DOM. No build step required. Just modern JavaScript. When you need production optimizations, add the optional Rust-based compiler.
6
6
 
@@ -105,6 +105,26 @@ Vibe supports bindings in three positions:
105
105
  <!-- /each -->
106
106
  ```
107
107
 
108
+ The "array" position accepts any JS expression evaluated in scope, not just a state path:
109
+
110
+ ```html
111
+ <!-- each items.filter(i => i.active) as item -->...<!-- /each -->
112
+ <!-- each Array.from({ length: count }) as n, index -->...<!-- /each -->
113
+ ```
114
+
115
+ **Keyed iteration** — give each row a stable identity with `(keyExpr)` so survivors keep their DOM (and listeners / animation state) when the list reorders or items are removed. The key comes before the optional index. Without a key, Vibe falls back to an index-coupled hash and bulk-re-renders the tail on reorder.
116
+
117
+ ```html
118
+ <!-- each rows as row (row.id) -->
119
+ <li>@[row.label]</li>
120
+ <!-- /each -->
121
+
122
+ <!-- key + index -->
123
+ <!-- each rows as row (row.id), index -->
124
+ <li>@[index]: @[row.label]</li>
125
+ <!-- /each -->
126
+ ```
127
+
108
128
  Nested iteration with dot paths:
109
129
 
110
130
  ```html
@@ -218,6 +238,19 @@ Skip reactive processing for an element:
218
238
  <code vibe-dehydrate>@[this] displays literally</code>
219
239
  ```
220
240
 
241
+ ### Raw HTML (`$.unsafe`)
242
+
243
+ `@[expr]` escapes its output (`textContent`) — safe by default. To render **trusted** markup instead, wrap the value in `$.unsafe(...)`; the binding sets `innerHTML`. This is Vibe's equivalent of Svelte `{@html}` / Vue `v-html` / React `dangerouslySetInnerHTML`.
244
+
245
+ ```html
246
+ <p>@[$.unsafe(description)]</p>
247
+ ```
248
+
249
+ - The binding **must be the sole content of its element** (innerHTML semantics). Mixed into surrounding text, it falls back to escaped literal text.
250
+ - Injected markup is **inert** — `@[...]` / `<!-- if -->` / `<!-- each -->` inside it are not processed (matches Svelte `{@html}`); the subtree is opaque to the MutationObserver.
251
+ - Fully **reactive** — re-renders on value change; works in iterations, conditionals, and components. Compiled mode paints the markup at stamp time and re-hydrates at runtime.
252
+ - **Trusted input only** — no sanitizing. Don't pass user-supplied strings.
253
+
221
254
  ### Internal Names (don't collide)
222
255
 
223
256
  These are used by the runtime — don't repurpose them in your code:
@@ -64,7 +64,14 @@ impl<'a> ValueStamper<'a> {
64
64
  context.with(|ctx| -> Result<(), String> {
65
65
  let state_str = serde_json::to_string(state)
66
66
  .map_err(|e| format!("Failed to serialize state: {}", e))?;
67
- ctx.eval::<(), _>(format!("const $ = {}; Object.assign(globalThis, $)", state_str))
67
+ // Expose state both as bare globals (via Object.assign) and as a
68
+ // persistent `$` global carrying the runtime `unsafe` helper, so a
69
+ // `@[$.unsafe(expr)]` binding stamps its trusted string raw at build
70
+ // time — mirroring the runtime, where `$.unsafe` marks raw HTML.
71
+ ctx.eval::<(), _>(format!(
72
+ "const $ = {}; Object.assign(globalThis, $); $.unsafe = (s) => s; globalThis.$ = $",
73
+ state_str
74
+ ))
68
75
  .map_err(|e| format!("Failed to set up state in QuickJS: {:?}", e))?;
69
76
  Ok(())
70
77
  })?;
@@ -714,6 +721,25 @@ mod tests {
714
721
  assert_eq!(result, "<div>Hello @[missing]</div>");
715
722
  }
716
723
 
724
+ #[test]
725
+ fn stamp_unsafe_emits_raw_html() {
726
+ let state = json!({ "desc": "Gain <span data-green>+3%</span>." });
727
+ let stamper = ValueStamper::new(&state, false).unwrap();
728
+ let html = String::from("<p>@[$.unsafe(desc)]</p>");
729
+ let result = stamper.stamp_html(html).unwrap();
730
+ // Raw markup is painted directly — not escaped, not left as a marker.
731
+ assert_eq!(result, "<p>Gain <span data-green>+3%</span>.</p>");
732
+ }
733
+
734
+ #[test]
735
+ fn stamp_unsafe_nested_path() {
736
+ let state = json!({ "tooltip": { "props": { "description": "<em>x</em>" } } });
737
+ let stamper = ValueStamper::new(&state, false).unwrap();
738
+ let html = String::from("<p>@[$.unsafe(tooltip.props.description)]</p>");
739
+ let result = stamper.stamp_html(html).unwrap();
740
+ assert_eq!(result, "<p><em>x</em></p>");
741
+ }
742
+
717
743
  #[test]
718
744
  fn stamp_attribute_binding() {
719
745
  let state = json!({ "firstName": "John" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.9.7",
3
+ "version": "1.9.9",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -1,7 +1,74 @@
1
1
  import { resolvePath, deepEqual } from "./iteration-utils.js";
2
2
  import { extractDependencies } from "./conditionals.js";
3
3
  import { BINDING_REGEX } from "./constants.js";
4
- import { evalInScope, resolveThisPath } from "./utils.js";
4
+ import { evalInScope, resolveThisPath, ownKeysOf, scopedOverlayOf } from "./utils.js";
5
+
6
+ // The @[...] bindings in a tree node are static: the parsed template string and
7
+ // the element's component scope don't change between renders. Re-running the
8
+ // regex + resolveThisPath on every affected-walk was a large per-frame cost in
9
+ // hot iterations (combat at 60fps re-extracts every binding of every row). Cache
10
+ // the extracted + scope-resolved bindings on the node, keyed by the parsed source
11
+ // and element so a re-parse or element swap recomputes.
12
+ const textBindingsOf = (tree) => {
13
+ if (tree._tbSrc === tree.parsed && tree._tbEl === tree.element) return tree._tb;
14
+ const out = [];
15
+ BINDING_REGEX.lastIndex = 0;
16
+ let m;
17
+ while ((m = BINDING_REGEX.exec(tree.parsed))) {
18
+ const resolvedInner = resolveThisPath(m[1], tree.element);
19
+ out.push({
20
+ outer: m[0],
21
+ inner: m[1],
22
+ input: tree.parsed,
23
+ resolvedInner,
24
+ tokens: tokensOf(resolvedInner),
25
+ });
26
+ }
27
+ tree._tb = out;
28
+ tree._tbSrc = tree.parsed;
29
+ tree._tbEl = tree.element;
30
+ return out;
31
+ };
32
+
33
+ const attrBindingsOf = (tree) => {
34
+ if (tree._abSrc === tree.attributes && tree._abEl === tree.element) return tree._ab;
35
+ const out = [];
36
+ for (const attrName in tree.attributes) {
37
+ const attrValue = tree.attributes[attrName];
38
+ BINDING_REGEX.lastIndex = 0;
39
+ const ms = [];
40
+ let m;
41
+ while ((m = BINDING_REGEX.exec(attrValue))) {
42
+ const resolvedInner = resolveThisPath(m[1], tree.element);
43
+ ms.push({ outer: m[0], inner: m[1], resolvedInner, tokens: tokensOf(resolvedInner) });
44
+ }
45
+ if (ms.length) out.push({ attrName, matches: ms });
46
+ }
47
+ tree._ab = out;
48
+ tree._abSrc = tree.attributes;
49
+ tree._abEl = tree.element;
50
+ return out;
51
+ };
52
+
53
+ // Name bindings (`<icon @[fx.icon]>`) live as raw strings on tree.nameBindings.
54
+ // Extract + this-resolve + tokenize them once, same cache discipline as the
55
+ // text/attr binding caches above.
56
+ const nameBindingsOf = (tree) => {
57
+ if (tree._nbSrc === tree.nameBindings && tree._nbEl === tree.element) return tree._nb;
58
+ const out = [];
59
+ for (const nameBinding of tree.nameBindings) {
60
+ BINDING_REGEX.lastIndex = 0;
61
+ let m;
62
+ while ((m = BINDING_REGEX.exec(nameBinding))) {
63
+ const resolvedInner = resolveThisPath(m[1], tree.element);
64
+ out.push({ nameBinding, outer: m[0], inner: m[1], resolvedInner, tokens: tokensOf(resolvedInner) });
65
+ }
66
+ }
67
+ tree._nb = out;
68
+ tree._nbSrc = tree.nameBindings;
69
+ tree._nbEl = tree.element;
70
+ return out;
71
+ };
5
72
 
6
73
  // Evaluate conditional expression
7
74
  const evaluateCondition = (expression, state, element = null) =>
@@ -18,26 +85,99 @@ const isIdentChar = (c) =>
18
85
  c === "_" ||
19
86
  c === "$";
20
87
 
21
- const matchesKey = (matchStr, key) => {
22
- if (
23
- matchStr === key ||
24
- matchStr.startsWith(key + ".") ||
25
- matchStr.startsWith(key + "[")
26
- )
27
- return true;
28
-
29
- // Search for key as a standalone identifier (word boundaries on both sides)
88
+ // Split an expression into its identifier tokens — every maximal run of
89
+ // identifier characters. This is exactly the set of standalone identifiers the
90
+ // old per-key `matchesKey` scan could match, so intersecting these tokens with
91
+ // the state-key set is equivalent to "which state keys does this binding read"
92
+ // for identifier-named keys, but costs O(expr length) once (cached) instead of
93
+ // O(stateKeys) on every frame. Deduped so the per-frame change check stays tight.
94
+ const tokensOf = (expr) => {
95
+ const seen = new Set();
30
96
  let i = 0;
31
- while ((i = matchStr.indexOf(key, i)) !== -1) {
32
- const before = i === 0 ? "" : matchStr[i - 1];
33
- const after =
34
- i + key.length >= matchStr.length ? "" : matchStr[i + key.length];
35
- if (!isIdentChar(before) && !isIdentChar(after)) return true;
36
- i += key.length;
97
+ const n = expr.length;
98
+ while (i < n) {
99
+ if (isIdentChar(expr[i])) {
100
+ let j = i + 1;
101
+ while (j < n && isIdentChar(expr[j])) j++;
102
+ seen.add(expr.slice(i, j));
103
+ i = j;
104
+ } else i++;
105
+ }
106
+ return [...seen];
107
+ };
108
+
109
+ // Memoize the own-key set of a state snapshot so binding checks do O(1)
110
+ // membership tests instead of scanning the key array. Keyed by the snapshot
111
+ // object itself — scoped-state proxies and merged plain objects are reused
112
+ // across every node of a single affected() descent, so the Set is built once
113
+ // per snapshot, not once per binding.
114
+ const keySetCache = new WeakMap();
115
+ const keySetOf = (snapshot) => {
116
+ let set = keySetCache.get(snapshot);
117
+ if (!set) {
118
+ set = new Set(ownKeysOf(snapshot));
119
+ keySetCache.set(snapshot, set);
120
+ }
121
+ return set;
122
+ };
123
+
124
+ // A binding is affected on update when it reads a state key whose value changed,
125
+ // OR when it reads no known state key at all (can't prove it's unaffected, so
126
+ // re-evaluate — hydrate self-guards the DOM write). False can only be returned
127
+ // when every key the binding reads is present AND unchanged.
128
+ const bindingAffected = (tokens, keySet, state, newState) => {
129
+ let matched = false;
130
+ for (let i = 0; i < tokens.length; i++) {
131
+ const t = tokens[i];
132
+ if (keySet.has(t)) {
133
+ matched = true;
134
+ if (state[t] !== newState[t]) return true;
135
+ }
37
136
  }
38
- return false;
137
+ return !matched;
39
138
  };
40
139
 
140
+ // Name bindings (`<icon @[fx.convertIcon]>`) arrive lowercased from HTML while
141
+ // state keys stay camelCase, so they match keys case-insensitively. This map
142
+ // resolves a (possibly lowercased) token back to its real key. Memoized per
143
+ // snapshot like keySetOf.
144
+ const nameKeyMapCache = new WeakMap();
145
+ const nameKeyMapOf = (snapshot) => {
146
+ let map = nameKeyMapCache.get(snapshot);
147
+ if (!map) {
148
+ map = new Map();
149
+ for (const key of ownKeysOf(snapshot)) {
150
+ map.set(key, key);
151
+ const lower = key.toLowerCase();
152
+ if (!map.has(lower)) map.set(lower, key);
153
+ }
154
+ nameKeyMapCache.set(snapshot, map);
155
+ }
156
+ return map;
157
+ };
158
+
159
+ const nameBindingAffected = (tokens, keyMap, state, newState) => {
160
+ let matched = false;
161
+ for (let i = 0; i < tokens.length; i++) {
162
+ const realKey = keyMap.get(tokens[i]);
163
+ if (realKey !== undefined) {
164
+ matched = true;
165
+ if (state[realKey] !== newState[realKey]) return true;
166
+ }
167
+ }
168
+ return !matched;
169
+ };
170
+
171
+ // Identifier-followed-by-`(` — i.e. an attempted function or method call.
172
+ // Used to flag conditional expressions whose result can't be trusted to the
173
+ // snapshot-equality short-circuit because the called function may read live
174
+ // `$` (or other state-holding globals) via closure rather than the state
175
+ // snapshot affected.js passes in. False positives are acceptable: when the
176
+ // expression contains a call, we fall back to "if any state key changed,
177
+ // re-evaluate"; mountBranch's branchChanged guard suppresses the no-op DOM
178
+ // churn when the value didn't actually flip.
179
+ const CALL_EXPR_REGEX = /\b[A-Za-z_$][\w$]*\s*\(/;
180
+
41
181
  // Resolve a clone-list entry to its live counterpart. processComponent swaps
42
182
  // the original `<component src>` for a post-process `<component>` wrapper and
43
183
  // records the new node on the original via `_vibeReplacedBy`. Mirrors
@@ -160,8 +300,18 @@ const recursive = (
160
300
  // overlaying spread of state/newState then writes the *current*
161
301
  // values from this update cycle. Both merged states are plain
162
302
  // objects with up-to-date values.
163
- const mergedOldState = { ...instance.scopedState, ...state };
164
- const mergedNewState = { ...instance.scopedState, ...newState };
303
+ // The merged snapshot only needs this row's aliases overlaid on the
304
+ // current globals (`state`/`newState` already carry every global key).
305
+ // Recover the small overlay recorded at scope creation and plain-merge
306
+ // it — spreading instance.scopedState (a Proxy) instead fires its traps
307
+ // over every global key, per instance, per frame (a combat hot spot).
308
+ const overlay = scopedOverlayOf(instance.scopedState);
309
+ const mergedOldState = overlay
310
+ ? { ...state, ...overlay }
311
+ : { ...instance.scopedState, ...state };
312
+ const mergedNewState = overlay
313
+ ? { ...newState, ...overlay }
314
+ : { ...instance.scopedState, ...newState };
165
315
  // scopedStateForHydration must reflect the new state so hydrate's
166
316
  // bindings inside the row see post-update values. instance.scopedState
167
317
  // is frozen against whatever target renderIteration was called with
@@ -235,6 +385,29 @@ const recursive = (
235
385
  return affected;
236
386
  }
237
387
 
388
+ // Function-call short-circuit: when the expression contains a `(`-style
389
+ // call, the snapshot eval can be blind — the called helper may read live
390
+ // `$` via closure rather than the passed state, so `oldValue` and
391
+ // `newValue` both run against the *current* values and the equality check
392
+ // never fires. Flag as affected whenever any state key actually changed
393
+ // this cycle so updateConditional re-runs against live state; if the
394
+ // result hasn't really flipped, mountBranch's branchChanged guard absorbs
395
+ // the call cheaply. Skip during initial hydration (state === newState).
396
+ if (state !== newState && CALL_EXPR_REGEX.test(tree.meta.expression)) {
397
+ const stateKeys = Object.keys(state);
398
+ const anyChanged = stateKeys.some((k) => state[k] !== newState[k]);
399
+ if (anyChanged) {
400
+ affected.push({
401
+ type: "conditional",
402
+ node: tree,
403
+ changeType: "expression-fn-call",
404
+ scopedState: newState,
405
+ oldScopedState: state,
406
+ });
407
+ return affected;
408
+ }
409
+ }
410
+
238
411
  // Condition didn't change, check for affected elements inside active branch
239
412
  if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
240
413
  return recursive(
@@ -250,70 +423,30 @@ const recursive = (
250
423
  return affected;
251
424
  }
252
425
 
253
- // Reset regex state for reuse
254
- BINDING_REGEX.lastIndex = 0;
255
-
256
- const matches = [];
257
- let match;
258
- while ((match = BINDING_REGEX.exec(tree.parsed))) {
259
- matches.push({ outer: match[0], inner: match[1], input: match.input });
260
- }
426
+ const matches = textBindingsOf(tree);
261
427
 
262
428
  if (matches.length) {
263
- const shallowState = Object.keys(state);
264
- const shallowNewState = Object.keys(newState);
429
+ // A text node is hydrated as a whole (every @[…] re-interpolated together),
430
+ // so the node is all-or-nothing: if any of its bindings is affected, push
431
+ // them all. Initial hydration affects everything.
265
432
  const isInitialHydration = state === newState;
266
-
267
- let hasAffected = false;
268
- const checkedMatches = [];
269
-
270
- for (const m of matches) {
271
- // Resolve this.property to componentId.property
272
- const resolvedInner = resolveThisPath(m.inner, tree.element);
273
-
274
- const noMatch = !shallowState.some((key) =>
275
- matchesKey(resolvedInner, key),
276
- );
277
-
278
- let shouldAffect = false;
279
- let relevantKeys = [];
280
-
281
- if (isInitialHydration) {
282
- // Initial hydration: affect all matched keys
283
- relevantKeys = shallowNewState.filter((key) =>
284
- matchesKey(resolvedInner, key),
285
- );
286
- shouldAffect = noMatch || relevantKeys.length > 0;
287
- } else {
288
- // Update: only affect if value changed
289
- const changedKeys = shallowNewState.filter(
290
- (key) =>
291
- matchesKey(resolvedInner, key) && state[key] !== newState[key],
292
- );
293
- relevantKeys =
294
- changedKeys.length > 0
295
- ? changedKeys
296
- : shallowState.filter((key) => matchesKey(resolvedInner, key));
297
- shouldAffect = noMatch || changedKeys.length > 0;
298
- }
299
-
300
- if (shouldAffect) {
301
- hasAffected = true;
433
+ let hasAffected = isInitialHydration;
434
+ if (!isInitialHydration) {
435
+ const keySet = keySetOf(newState);
436
+ for (const m of matches) {
437
+ if (bindingAffected(m.tokens, keySet, state, newState)) {
438
+ hasAffected = true;
439
+ break;
440
+ }
302
441
  }
303
-
304
- checkedMatches.push({
305
- ...m,
306
- matches: relevantKeys,
307
- });
308
442
  }
309
443
 
310
444
  if (hasAffected) {
311
- for (const m of checkedMatches) {
445
+ for (const m of matches) {
312
446
  affected.push({
313
447
  matchOuter: m.outer,
314
448
  matchInner: m.inner,
315
449
  input: m.input,
316
- matches: m.matches,
317
450
  element: tree.element,
318
451
  textNode: tree.textNode, // Reference to specific text node (prevents wiping children)
319
452
  scopedState: scopedStateForHydration, // Pass scoped state from iteration context
@@ -324,42 +457,14 @@ const recursive = (
324
457
 
325
458
  // Check attribute bindings
326
459
  if (tree.attributes) {
327
- const shallowState = Object.keys(state);
328
- const shallowNewState = Object.keys(newState);
329
460
  const isInitialHydration = state === newState;
461
+ const keySet = isInitialHydration ? null : keySetOf(newState);
330
462
 
331
- for (const [attrName, attrValue] of Object.entries(tree.attributes)) {
332
- BINDING_REGEX.lastIndex = 0;
333
- const attrMatches = [];
334
- let attrMatch;
335
- while ((attrMatch = BINDING_REGEX.exec(attrValue))) {
336
- attrMatches.push({ outer: attrMatch[0], inner: attrMatch[1] });
337
- }
338
-
463
+ for (const { attrName, matches: attrMatches } of attrBindingsOf(tree)) {
464
+ const attrValue = tree.attributes[attrName];
339
465
  for (const m of attrMatches) {
340
- // Resolve this.property to componentId.property
341
- const resolvedInner = resolveThisPath(m.inner, tree.element);
342
-
343
- const noMatch = !shallowState.some((key) =>
344
- matchesKey(resolvedInner, key),
345
- );
346
-
347
- let shouldAffect = false;
348
-
349
- if (isInitialHydration) {
350
- // Initial hydration: affect all matched keys
351
- const newMatches = shallowNewState.filter((key) =>
352
- matchesKey(resolvedInner, key),
353
- );
354
- shouldAffect = noMatch || newMatches.length > 0;
355
- } else {
356
- // Update: only affect if value changed
357
- const changedKeys = shallowNewState.filter(
358
- (key) =>
359
- matchesKey(resolvedInner, key) && state[key] !== newState[key],
360
- );
361
- shouldAffect = noMatch || changedKeys.length > 0;
362
- }
466
+ const shouldAffect =
467
+ isInitialHydration || bindingAffected(m.tokens, keySet, state, newState);
363
468
 
364
469
  if (shouldAffect) {
365
470
  affected.push({
@@ -378,51 +483,22 @@ const recursive = (
378
483
 
379
484
  // Check name bindings (bindings in attribute names)
380
485
  if (tree.nameBindings) {
381
- const shallowState = Object.keys(state);
382
- const shallowNewState = Object.keys(newState);
383
486
  const isInitialHydration = state === newState;
487
+ const keyMap = isInitialHydration ? null : nameKeyMapOf(newState);
384
488
 
385
- for (const nameBinding of tree.nameBindings) {
386
- BINDING_REGEX.lastIndex = 0;
387
- const nameMatches = [];
388
- let nameMatch;
389
- while ((nameMatch = BINDING_REGEX.exec(nameBinding))) {
390
- nameMatches.push({ outer: nameMatch[0], inner: nameMatch[1] });
391
- }
489
+ for (const m of nameBindingsOf(tree)) {
490
+ const shouldAffect =
491
+ isInitialHydration || nameBindingAffected(m.tokens, keyMap, state, newState);
392
492
 
393
- for (const m of nameMatches) {
394
- // Resolve this.property to componentId.property
395
- const resolvedInner = resolveThisPath(m.inner, tree.element);
396
- // HTML lowercases attribute names, so expression may be lowercase while state
397
- // keys are camelCase. Match case-insensitively by trying both direct and lowercased.
398
- const matchKey = (key) =>
399
- matchesKey(resolvedInner, key) ||
400
- matchesKey(resolvedInner, key.toLowerCase());
401
-
402
- const noMatch = !shallowState.some(matchKey);
403
-
404
- let shouldAffect = false;
405
-
406
- if (isInitialHydration) {
407
- const newMatches = shallowNewState.filter(matchKey);
408
- shouldAffect = noMatch || newMatches.length > 0;
409
- } else {
410
- const changedKeys = shallowNewState.filter(
411
- (key) => matchKey(key) && state[key] !== newState[key],
412
- );
413
- shouldAffect = noMatch || changedKeys.length > 0;
414
- }
415
-
416
- if (shouldAffect) {
417
- affected.push({
418
- type: "nameBinding",
419
- nameBinding,
420
- matchOuter: m.outer,
421
- matchInner: m.inner, // Keep original, evalInScope will resolve this.
422
- element: tree.element,
423
- scopedState: scopedStateForHydration,
424
- });
425
- }
493
+ if (shouldAffect) {
494
+ affected.push({
495
+ type: "nameBinding",
496
+ nameBinding: m.nameBinding,
497
+ matchOuter: m.outer,
498
+ matchInner: m.inner, // Keep original, evalInScope will resolve this.
499
+ element: tree.element,
500
+ scopedState: scopedStateForHydration,
501
+ });
426
502
  }
427
503
  }
428
504
  }
@@ -1,7 +1,8 @@
1
1
  import { updateIteration } from './iterate.js';
2
- import { updateConditional } from './conditionals.js';
2
+ import { updateConditional, managedNodes } from './conditionals.js';
3
3
  import { VALUE_ATTRS, DOM_PROPERTIES, BINDING_REGEX, PURE_BINDING_REGEX } from './constants.js';
4
4
  import { evalInScope, resolveCaseInsensitivePath } from './utils.js';
5
+ import { RawHtml } from './raw-html.js';
5
6
 
6
7
  export default (affected, state, manifest = {}, oldState = {}) => {
7
8
  affected.forEach((aff) => {
@@ -131,6 +132,27 @@ export default (affected, state, manifest = {}, oldState = {}) => {
131
132
  // Evaluate the expression with state as context
132
133
  const evaluated = evalInScope(matchInner, effectiveState, element);
133
134
 
135
+ // Raw-HTML render: `$.unsafe(str)` returns a RawHtml marker. When the
136
+ // binding is the sole content of its element (`<p>@[$.unsafe(x)]</p>`),
137
+ // set innerHTML from the trusted string instead of escaping via
138
+ // textContent. The injected subtree is marked opaque (managedNodes) so
139
+ // the MutationObserver never re-walks or hydrates it — raw HTML is inert,
140
+ // matching Svelte {@html}. Any non-pure use (marker spliced into
141
+ // surrounding text, or an element with other children) falls through to
142
+ // the normal path, where RawHtml.toString() degrades to escaped text.
143
+ const isPureBinding = input.trim() === matchOuter;
144
+ if (evaluated instanceof RawHtml && isPureBinding &&
145
+ (element._vibeRawHtml || element.childNodes.length === 1)) {
146
+ const html = evaluated.html;
147
+ if (element._vibeRawHtmlValue !== html) {
148
+ element.innerHTML = html;
149
+ for (const child of element.children) managedNodes.add(child);
150
+ element._vibeRawHtmlValue = html;
151
+ }
152
+ element._vibeRawHtml = true;
153
+ return;
154
+ }
155
+
134
156
  const toReplace = input.replaceAll(matchOuter, evaluated).trim();
135
157
 
136
158
  affected.forEach((innerAff) => {
package/runtime/index.js CHANGED
@@ -3,7 +3,8 @@ import parse from './parse.js';
3
3
  import createManifest from './manifest.js';
4
4
  import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
- import { deepMerge, hash } from './utils.js';
6
+ import { deepMerge, hash, setRootState } from './utils.js';
7
+ import { unsafe } from './raw-html.js';
7
8
  import { renderAllIterations, setRenderAllConditionals, releaseOrphanedIterationProps } from './iterate.js';
8
9
  import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
9
10
  import { installScopeResolver } from './loop-scope.js';
@@ -655,6 +656,16 @@ const main = (s, config = {}, stringSelector = '') => {
655
656
  enumerable: false,
656
657
  });
657
658
 
659
+ // Mark a trusted string as raw HTML. A binding that is the sole content of
660
+ // its element — `<p>@[$.unsafe(desc)]</p>` — sets innerHTML from the string
661
+ // instead of escaping it via textContent. Trusted input only (no sanitizing,
662
+ // like Svelte {@html}). Non-enumerable so it never leaks into state snapshots.
663
+ Object.defineProperty($, 'unsafe', {
664
+ value: unsafe,
665
+ enumerable: false,
666
+ configurable: true,
667
+ });
668
+
658
669
  // Pure-render path for surgical component HMR. Given raw component template
659
670
  // HTML, callsite props, slot HTML, and existing componentIds, returns the
660
671
  // processed HTML string the plugin's HMR handler can hand to $.reconcile.
@@ -672,6 +683,29 @@ const main = (s, config = {}, stringSelector = '') => {
672
683
  // manifest's node-path entry iteration.
673
684
  Object.defineProperty(manifest, '__live', { value: $, enumerable: false, configurable: true });
674
685
 
686
+ // Pair the flat manifest (dotPath -> element) with its parsed tree so removal
687
+ // paths can prune both views together. The page MutationObserver is
688
+ // disconnected while Vibe renders (iteration/conditional teardown removes DOM
689
+ // unobserved), so those paths must prune the manifest + tree themselves; this
690
+ // gives them the tree root without threading it through every call. Same
691
+ // non-enumerable contract as __live.
692
+ Object.defineProperty(manifest, '__tree', { value: parsedTree, enumerable: false, configurable: true });
693
+
694
+ // Bind `$` inside every expression to this live root proxy (see setRootState
695
+ // in utils.js). Done before the first hydration pass so `$.unsafe` and the
696
+ // other reserved methods are reachable from the initial render onward.
697
+ setRootState($);
698
+
699
+ // Publish the real proxy on `window.$` BEFORE the first hydration pass.
700
+ // boot.js sets `window.$ = main(...)`, but until main returns it's the
701
+ // pre-boot placeholder (vibeInstance with no state keys). User helpers
702
+ // defined on `window` that close over `$` — e.g. a global `brawlerActivity`
703
+ // function reading `$.combat?.duration` — would then read the placeholder
704
+ // during initial render and treat the whole world as empty. Assigning the
705
+ // live proxy here lets those closures see the right `$` from the very
706
+ // first conditional/binding eval.
707
+ if (typeof window !== 'undefined') window.$ = $;
708
+
675
709
  // Initial hydration - pass plain values so iteration can do reference comparison
676
710
  const initialState = extractPlainValue($);
677
711
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -2,7 +2,7 @@ import parse from './parse.js';
2
2
  import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
- import { resolveThisPath, evalInScope, findComponentIdForElement } from './utils.js';
5
+ import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys } from './utils.js';
6
6
  import { managedNodes } from './conditionals.js';
7
7
  import { stampInstanceScopes } from './loop-scope.js';
8
8
  import {
@@ -351,36 +351,54 @@ const liveCloneNode = (node) => {
351
351
  return cur;
352
352
  };
353
353
 
354
+ // Walk a row's live cloned nodes and invoke `fn` for every inlined-component
355
+ // wrapper carrying `marker` — the resolved node itself plus any
356
+ // `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
357
+ // inlined-hydrate passes are identical except for this marker and the per-wrapper
358
+ // work, so they share this walk. (`[data-vibe-iter-prop]` always implies
359
+ // `_vibeIterPropExprs`, set together at mount, so filtering descendants by the
360
+ // marker matches the historical "take all, skip those without exprs" behavior.)
361
+ const forEachIterWrapper = (clonedNodes, marker, fn) => {
362
+ for (let n = 0; n < clonedNodes.length; n++) {
363
+ const node = liveCloneNode(clonedNodes[n]);
364
+ if (!node || node.nodeType !== 1) continue;
365
+ if (node[marker]) fn(node);
366
+ const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
367
+ if (found) for (let i = 0; i < found.length; i++) {
368
+ if (found[i][marker]) fn(found[i]);
369
+ }
370
+ }
371
+ };
372
+
354
373
  // Walk a row's clones for any element tagged as iteration-prop owner —
355
374
  // pre-process `<component src>` (still has the src attribute) and post-process
356
375
  // `<component>` wrappers both carry `_vibeIterPropExprs`. For each tracked
357
376
  // expression, re-evaluate against the row's new scoped state and write into
358
377
  // the registry slot the inlined bindings already reference. Idempotent: same
359
378
  // scoped state → same value → no-op write.
379
+ // Returns the set of registry slot ids whose value actually changed this
380
+ // refresh. A slot holding the same reference (e.g. a static ability array on a
381
+ // combatant that only moved) is a no-op, so forceRegistryBackedIterationUpdates
382
+ // can skip re-diffing the nested iteration it feeds — the dominant cost when a
383
+ // row carries large static nested iterations.
360
384
  const refreshIterationComponentProps = (clonedNodes, scopedState) => {
361
- for (let n = 0; n < clonedNodes.length; n++) {
362
- const node = liveCloneNode(clonedNodes[n]);
363
- if (!node || node.nodeType !== 1) continue;
364
- const wrappers = [];
365
- if (node._vibeIterPropExprs) wrappers.push(node);
366
- const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
367
- if (found) for (let i = 0; i < found.length; i++) wrappers.push(found[i]);
368
- for (let w = 0; w < wrappers.length; w++) {
369
- const el = wrappers[w];
370
- const exprs = el._vibeIterPropExprs;
371
- if (!exprs) continue;
372
- const registry = ensureIterPropsRegistry();
373
- for (let e = 0; e < exprs.length; e++) {
374
- try {
375
- const value = evalInScope(exprs[e].expr, scopedState, el);
376
- registry[exprs[e].id] = value;
377
- } catch {
378
- // Leave previous registry value in place — same fail-safe as
379
- // resolveIterationComponentProps's mount-time path.
385
+ const changed = new Set();
386
+ const registry = ensureIterPropsRegistry();
387
+ forEachIterWrapper(clonedNodes, '_vibeIterPropExprs', (el) => {
388
+ for (const { id, expr } of el._vibeIterPropExprs) {
389
+ try {
390
+ const value = evalInScope(expr, scopedState, el);
391
+ if (registry[id] !== value) {
392
+ registry[id] = value;
393
+ changed.add(id);
380
394
  }
395
+ } catch {
396
+ // Leave previous registry value in place — same fail-safe as
397
+ // resolveIterationComponentProps's mount-time path.
381
398
  }
382
399
  }
383
- }
400
+ });
401
+ return changed;
384
402
  };
385
403
 
386
404
  // Walk an inlined component's parsed tree and force `updateIteration` on any
@@ -394,18 +412,21 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
394
412
  // in sync. Without this, an `<inner-component>` whose template iterates over
395
413
  // an array prop stays frozen on its initial-render items when the prop's
396
414
  // contents change.
397
- const REGISTRY_PATH_REGEX = /__vibeIterProps\._p\d+/;
398
- const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope) => {
415
+ const REGISTRY_SLOT_REGEX = /__vibeIterProps\.(_p\d+)/;
416
+ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
399
417
  if (!tree) return;
400
418
  if (tree.type === 'iteration') {
401
419
  const arrPath = tree.meta?.arrayPath;
402
- if (arrPath && REGISTRY_PATH_REGEX.test(arrPath)) {
420
+ const slot = arrPath && arrPath.match(REGISTRY_SLOT_REGEX);
421
+ // Skip iterations whose backing slot didn't change this cycle. changedSlots
422
+ // is undefined only on legacy/unguarded calls — fall back to always-update.
423
+ if (slot && (!changedSlots || changedSlots.has(slot[1]))) {
403
424
  updateIteration(tree, state, state, manifest, parentScope);
404
425
  }
405
426
  }
406
427
  if (tree.children) {
407
428
  for (const k in tree.children) {
408
- forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope);
429
+ forceRegistryBackedIterationUpdates(tree.children[k], state, manifest, parentScope, changedSlots);
409
430
  }
410
431
  }
411
432
  };
@@ -416,30 +437,19 @@ const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope)
416
437
  // component.js) — that tree retains the original `@[...]` binding text even
417
438
  // after the wrapper's live DOM has been hydrated, so subsequent affected→
418
439
  // hydrate passes work the same way they would on initial render.
419
- const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest) => {
420
- for (let n = 0; n < clonedNodes.length; n++) {
421
- const node = liveCloneNode(clonedNodes[n]);
422
- if (!node || node.nodeType !== 1) continue;
423
- const wrappers = [];
424
- if (node._vibeIterTree) wrappers.push(node);
425
- const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
426
- if (found) for (let i = 0; i < found.length; i++) {
427
- if (found[i]._vibeIterTree) wrappers.push(found[i]);
428
- }
429
- for (let w = 0; w < wrappers.length; w++) {
430
- const wrapper = wrappers[w];
431
- const tree = wrapper._vibeIterTree;
432
- const affectedList = affected(tree, oldState, newState);
433
- if (affectedList.length > 0) {
434
- hydrate(affectedList, newState, manifest, oldState);
435
- }
436
- // Bindings into the iteration-prop registry are visited above, but
437
- // iteration nodes whose arrayPath resolves through the registry need
438
- // explicit driving: the refreshed slot is a side effect that
439
- // `affected()` can't see. Walk the tree and update them directly.
440
- forceRegistryBackedIterationUpdates(tree, newState, manifest, {});
440
+ const hydrateInlinedIterationComponents = (clonedNodes, oldState, newState, manifest, changedSlots) => {
441
+ forEachIterWrapper(clonedNodes, '_vibeIterTree', (wrapper) => {
442
+ const tree = wrapper._vibeIterTree;
443
+ const affectedList = affected(tree, oldState, newState);
444
+ if (affectedList.length > 0) {
445
+ hydrate(affectedList, newState, manifest, oldState);
441
446
  }
442
- }
447
+ // Bindings into the iteration-prop registry are visited above, but iteration
448
+ // nodes whose arrayPath resolves through the registry need explicit driving:
449
+ // the refreshed slot is a side effect `affected()` can't see. Update only the
450
+ // iterations whose slot actually changed this cycle (changedSlots).
451
+ forceRegistryBackedIterationUpdates(tree, newState, manifest, {}, changedSlots);
452
+ });
443
453
  };
444
454
 
445
455
  // Apply DOM-property writes that compileBatchFn collected. Each batch row
@@ -764,7 +774,13 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
764
774
  ...Reflect.ownKeys(globalState),
765
775
  ])];
766
776
 
767
- return new Proxy(globalState, {
777
+ // Overlay = the aliases this proxy resolves locally (localVars wins over
778
+ // parentScope, matching the get order below). affected's descent reuses it for
779
+ // a cheap plain merge instead of materializing the proxy.
780
+ const overlay =
781
+ Object.keys(parentScope).length === 0 ? localVars : { ...parentScope, ...localVars };
782
+
783
+ const proxy = new Proxy(globalState, {
768
784
  get(target, prop) {
769
785
  if (prop in localVars) return localVars[prop];
770
786
  if (prop in parentScope) return parentScope[prop];
@@ -799,6 +815,8 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
799
815
  return Reflect.getOwnPropertyDescriptor(target, prop);
800
816
  },
801
817
  });
818
+
819
+ return rememberScopedKeys(proxy, cachedKeys, overlay);
802
820
  };
803
821
 
804
822
  // Render all iterations in the parsed tree
@@ -832,8 +850,7 @@ export const setRenderAllConditionals = (fn) => {
832
850
 
833
851
  // Initial render of an iteration block
834
852
  export const renderIteration = (iterationNode, state, manifest, parentScope = {}) => {
835
- const { arrayPath, itemAlias, indexAlias, template, startComment, endComment } =
836
- iterationNode.meta;
853
+ const { arrayPath, startComment, endComment } = iterationNode.meta;
837
854
 
838
855
  // Already rendered - updates go through updateIteration
839
856
  if (iterationNode.runtime.instances?.length > 0) {
@@ -898,55 +915,8 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
898
915
  // Fall through to runtime path if compiled failed
899
916
  }
900
917
 
901
- // Standard path: clone and hydrate each item (handles nested iterations/conditionals)
902
- const instances = [];
903
- const templateNodes = template.element.childNodes;
904
- const frag = document.createDocumentFragment();
905
- // Resolve the iteration's owning component once — `this.X` bindings inside
906
- // row content resolve against this id while the cloned subtree is still
907
- // detached during hydrate (see findComponentIdForElement's detached fallback).
908
- const componentId = findComponentIdForElement(startComment.parentElement);
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
-
916
- for (let i = 0; i < array.length; i++) {
917
- const item = array[i];
918
- const liveItem = liveItemAt(liveArray, i, item);
919
- const localVars = { [itemAlias]: liveItem, [indexAlias]: i };
920
- const scopedState = createScopedState(state, localVars, parentScope);
921
-
922
- // Clone, parse, hydrate
923
- const { element, tree, clonedNodes } = initializeBlock(templateNodes, scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
924
-
925
- // Pre-resolve <component src> binding props against iteration scope (see helper comment)
926
- resolveIterationComponentProps(clonedNodes, scopedState);
927
-
928
- // Collect nodes in DocumentFragment (single DOM insertion at end)
929
- for (let j = 0; j < clonedNodes.length; j++) {
930
- frag.appendChild(clonedNodes[j]);
931
- if (clonedNodes[j].nodeType === 1) managedNodes.add(clonedNodes[j]);
932
- }
933
-
934
- // Recursively render nested iterations and conditionals
935
- if (tree) {
936
- const nestedScope = { ...parentScope, ...localVars };
937
- renderAllIterations(tree, scopedState, manifest, nestedScope);
938
- _renderAllConditionals(tree, scopedState, manifest, nestedScope);
939
- }
940
-
941
- instances.push({ element, tree, item, liveItem, index: i, clonedNodes, scopedState });
942
- }
943
-
944
- // Single DOM insertion for all items
945
- parent.insertBefore(frag, endComment);
946
- iterationNode.runtime.instances = instances;
947
-
948
- // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
949
- stampScopes(iterationNode, manifest, parentScope);
918
+ // Standard path: one shared clone+hydrate render loop (also used by bulkReplace).
919
+ renderInstances(iterationNode, array, state, manifest, parentScope);
950
920
 
951
921
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
952
922
  // Also store runtime data on the DOM node so it persists across re-parses
@@ -1103,7 +1073,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1103
1073
  operations.forEach((op) => {
1104
1074
  switch (op.type) {
1105
1075
  case 'REMOVE':
1106
- removeInstance(iterationNode, op.index);
1076
+ removeInstance(iterationNode, op.index, manifest);
1107
1077
  break;
1108
1078
  case 'ADD':
1109
1079
  addInstance(iterationNode, op.item, op.index, newState, manifest, parentScope);
@@ -1135,18 +1105,97 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1135
1105
  stampScopes(iterationNode, manifest, parentScope);
1136
1106
  };
1137
1107
 
1108
+ // Prune removed iteration rows from the global manifest (dotPath -> element)
1109
+ // and parsed tree. Both views still reference rows that mounted globally-tracked
1110
+ // content (e.g. a <component src> registers its subtree in the manifest). Vibe
1111
+ // disconnects the page MutationObserver while it reconciles, so the removals
1112
+ // below are never observed — left unpruned, the entries pin detached subtrees
1113
+ // (memory leak) and bloat every later affected/hydrate walk (the
1114
+ // combat-fps-decays-per-reset bug). Scoped to exactly the removed nodes.
1115
+ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1116
+ if (!manifest || removedRoots.length === 0) return;
1117
+ const tree = manifest.__tree;
1118
+
1119
+ // Reverse the manifest (dotPath -> element) once so each removed root resolves
1120
+ // to its path. An element can be registered at more than one path (a component
1121
+ // wrapper appears at both its own node and an inlined child slot); keep the
1122
+ // SHORTEST so the path scopes the whole row subtree, not an inner slot.
1123
+ // __live / __tree are non-enumerable, so for-in skips them.
1124
+ const pathOf = new Map();
1125
+ for (const key in manifest) {
1126
+ const el = manifest[key];
1127
+ if (!el) continue;
1128
+ const existing = pathOf.get(el);
1129
+ if (existing === undefined || key.length < existing.length) pathOf.set(el, key);
1130
+ }
1131
+
1132
+ const removedEls = new Set(); // top-level removed roots — tree prune cascades subtrees
1133
+ const removedPaths = new Set(); // their manifest paths — manifest prune is path-scoped
1134
+ for (let i = 0; i < removedRoots.length; i++) {
1135
+ const root = removedRoots[i];
1136
+ if (!root) continue;
1137
+ removedEls.add(root);
1138
+ const path = pathOf.get(root);
1139
+ if (path !== undefined) removedPaths.add(path);
1140
+ }
1141
+ if (removedPaths.size === 0 && removedEls.size === 0) return;
1142
+
1143
+ // Manifest: drop every entry at or under a removed root's path. Path scope (not
1144
+ // element identity) is what catches content hoisted out of the DOM — an inactive
1145
+ // conditional branch template sits in a detached container yet stays registered
1146
+ // under its row's path.
1147
+ if (removedPaths.size > 0) {
1148
+ for (const key in manifest) {
1149
+ if (pathUnderRemoved(key, removedPaths)) delete manifest[key];
1150
+ }
1151
+ }
1152
+
1153
+ // Parsed tree: delete each removed root's node — its whole subtree (nested
1154
+ // conditionals/iterations and their branch templates) goes with it.
1155
+ if (tree) pruneTreeNodes(tree, removedEls);
1156
+ };
1157
+
1158
+ // True when `key` is, or is a descendant of, any path in `removedPaths`.
1159
+ const pathUnderRemoved = (key, removedPaths) => {
1160
+ if (removedPaths.has(key)) return true;
1161
+ for (let i = key.indexOf('.', 1); i !== -1; i = key.indexOf('.', i + 1)) {
1162
+ if (removedPaths.has(key.slice(0, i))) return true;
1163
+ }
1164
+ return false;
1165
+ };
1166
+
1167
+ const pruneTreeNodes = (node, removedEls) => {
1168
+ const children = node.children;
1169
+ if (!children) return;
1170
+ for (const key in children) {
1171
+ const child = children[key];
1172
+ if (!child) continue;
1173
+ if (removedEls.has(child.element)) delete children[key];
1174
+ else pruneTreeNodes(child, removedEls);
1175
+ }
1176
+ };
1177
+
1138
1178
  // Bulk replacement: clear all DOM and re-render from scratch
1139
1179
  // Used when arrays share no common keys (avoids O(n²) LCS)
1140
1180
  const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1141
1181
  const { template, startComment, endComment } = iterationNode.meta;
1142
1182
  const parent = startComment.parentNode;
1143
1183
 
1144
- // Clear all existing DOM between comments using Range (single operation)
1184
+ // Clear all existing DOM between comments using Range (single operation),
1185
+ // then prune the removed subtrees from the manifest + tree. Collect the LIVE
1186
+ // nodes in the range rather than the instances' clonedNodes: a <component src>
1187
+ // row is replaced in place by component.js, so clonedNodes can point at the
1188
+ // stale original wrapper, not the processed content actually being removed.
1145
1189
  if (iterationNode.runtime.instances.length > 0) {
1190
+ const removedRoots = [];
1191
+ for (let cur = startComment.nextSibling; cur && cur !== endComment; cur = cur.nextSibling) {
1192
+ removedRoots.push(cur);
1193
+ }
1146
1194
  const range = document.createRange();
1147
1195
  range.setStartAfter(startComment);
1148
1196
  range.setEndBefore(endComment);
1149
1197
  range.deleteContents();
1198
+ releaseRemovedSubtrees(removedRoots, manifest);
1150
1199
  }
1151
1200
 
1152
1201
  if (newArray.length === 0) {
@@ -1165,26 +1214,8 @@ const bulkReplace = (iterationNode, newArray, state, manifest, parentScope) => {
1165
1214
  return;
1166
1215
  }
1167
1216
 
1168
- // Complex templates: build each instance, batch-append into a DocumentFragment,
1169
- // finalize (mark managed + render nested), then commit to the DOM in one
1170
- // parent.insertBefore call.
1171
- const instances = [];
1172
- const frag = document.createDocumentFragment();
1173
- const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
1174
- for (let i = 0; i < newArray.length; i++) {
1175
- const built = buildInstance(iterationNode, newArray[i], i, state, parentScope, liveItemAt(liveArray, i, newArray[i]));
1176
- for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
1177
- finalizeInstance(built, manifest, parentScope);
1178
- instances.push({
1179
- element: built.element, tree: built.tree, item: newArray[i], liveItem: built.liveItem, index: i,
1180
- clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1181
- });
1182
- }
1183
- parent.insertBefore(frag, endComment);
1184
- iterationNode.runtime.instances = instances;
1185
-
1186
- // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1187
- stampScopes(iterationNode, manifest, parentScope);
1217
+ // Complex templates: one shared clone+hydrate render loop.
1218
+ renderInstances(iterationNode, newArray, state, manifest, parentScope);
1188
1219
  };
1189
1220
 
1190
1221
  // Find an instance's canonical in-DOM anchor (the first of its cloned nodes
@@ -1232,6 +1263,10 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1232
1263
  const anchor = findInstanceAnchor(instance, parent);
1233
1264
  const nextAnchor = resolveInsertBefore(iterationNode, index + 1, parent);
1234
1265
 
1266
+ // Return the live nodes actually removed so callers can prune them from the
1267
+ // manifest + tree (clonedNodes can be stale once a <component src> row is
1268
+ // replaced in place by component.js).
1269
+ const removed = [];
1235
1270
  if (anchor) {
1236
1271
  let cur = anchor;
1237
1272
  // endComment caps the walk even if nextAnchor ordering is ever
@@ -1239,6 +1274,7 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1239
1274
  while (cur && cur !== nextAnchor && cur !== endComment) {
1240
1275
  const nextSibling = cur.nextSibling;
1241
1276
  parent.removeChild(cur);
1277
+ removed.push(cur);
1242
1278
  cur = nextSibling;
1243
1279
  }
1244
1280
  }
@@ -1246,8 +1282,12 @@ const detachInstanceDom = (iterationNode, index, parent) => {
1246
1282
  const nodes = instance.clonedNodes || (instance.element ? [instance.element] : []);
1247
1283
  for (let i = 0; i < nodes.length; i++) {
1248
1284
  const n = nodes[i];
1249
- if (n && n.parentNode && n.parentNode !== parent) n.parentNode.removeChild(n);
1285
+ if (n && n.parentNode && n.parentNode !== parent) {
1286
+ n.parentNode.removeChild(n);
1287
+ removed.push(n);
1288
+ }
1250
1289
  }
1290
+ return removed;
1251
1291
  };
1252
1292
 
1253
1293
  // Build a fresh instance's DOM + tree + scope from the iteration template.
@@ -1285,6 +1325,31 @@ const finalizeInstance = (built, manifest, parentScope) => {
1285
1325
  }
1286
1326
  };
1287
1327
 
1328
+ // Build, finalize, and commit a fresh set of instances for `array` in one
1329
+ // batched DOM insertion, replacing iterationNode.runtime.instances. The single
1330
+ // clone+hydrate render loop shared by initial render (renderIteration) and full
1331
+ // rebuild (bulkReplace) — they differ only in their preamble, not this loop.
1332
+ const renderInstances = (iterationNode, array, state, manifest, parentScope) => {
1333
+ const { startComment, endComment } = iterationNode.meta;
1334
+ const parent = startComment.parentNode;
1335
+ const instances = [];
1336
+ const frag = document.createDocumentFragment();
1337
+ const liveArray = resolveLiveArray(iterationNode, manifest, parentScope);
1338
+ for (let i = 0; i < array.length; i++) {
1339
+ const built = buildInstance(iterationNode, array[i], i, state, parentScope, liveItemAt(liveArray, i, array[i]));
1340
+ for (let j = 0; j < built.clonedNodes.length; j++) frag.appendChild(built.clonedNodes[j]);
1341
+ finalizeInstance(built, manifest, parentScope);
1342
+ instances.push({
1343
+ element: built.element, tree: built.tree, item: array[i], liveItem: built.liveItem, index: i,
1344
+ clonedNodes: built.clonedNodes, scopedState: built.scopedState,
1345
+ });
1346
+ }
1347
+ parent.insertBefore(frag, endComment);
1348
+ iterationNode.runtime.instances = instances;
1349
+ // Stamp loop scope so `$scope(this,'alias')` handlers resolve the live item.
1350
+ stampScopes(iterationNode, manifest, parentScope);
1351
+ };
1352
+
1288
1353
  const addInstance = (iterationNode, item, index, state, manifest, parentScope) => {
1289
1354
  const parent = iterationNode.meta.startComment.parentNode;
1290
1355
  const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, item);
@@ -1297,10 +1362,11 @@ const addInstance = (iterationNode, item, index, state, manifest, parentScope) =
1297
1362
  });
1298
1363
  };
1299
1364
 
1300
- const removeInstance = (iterationNode, index) => {
1365
+ const removeInstance = (iterationNode, index, manifest) => {
1301
1366
  if (index < 0 || index >= iterationNode.runtime.instances.length) return;
1302
1367
  const parent = iterationNode.meta.startComment.parentNode;
1303
- if (parent) detachInstanceDom(iterationNode, index, parent);
1368
+ const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
1369
+ releaseRemovedSubtrees(removed, manifest);
1304
1370
  iterationNode.runtime.instances.splice(index, 1);
1305
1371
  };
1306
1372
 
@@ -1337,7 +1403,12 @@ const updateInstanceRebuild = (iterationNode, index, newItem, state, manifest, p
1337
1403
 
1338
1404
  const liveItem = liveItemAt(resolveLiveArray(iterationNode, manifest, parentScope), index, newItem);
1339
1405
  const built = buildInstance(iterationNode, newItem, index, state, parentScope, liveItem);
1340
- detachInstanceDom(iterationNode, index, parent);
1406
+ // Prune the OLD row's manifest/tree entries before finalizing the rebuilt one,
1407
+ // so the replaced subtree is released (same removal contract as removeInstance).
1408
+ // Order matters: prune the detached old nodes before finalizeInstance registers
1409
+ // the new ones, so the new entries are never touched.
1410
+ const removed = parent ? detachInstanceDom(iterationNode, index, parent) : [];
1411
+ releaseRemovedSubtrees(removed, manifest);
1341
1412
  const insertBefore = resolveInsertBefore(iterationNode, index + 1, parent);
1342
1413
  for (let i = 0; i < built.clonedNodes.length; i++) parent.insertBefore(built.clonedNodes[i], insertBefore);
1343
1414
  finalizeInstance(built, manifest, parentScope);
@@ -1371,19 +1442,25 @@ const updateInstance = (iterationNode, index, newItem, oldState, newState, manif
1371
1442
  }
1372
1443
 
1373
1444
  const { itemAlias, indexAlias } = iterationNode.meta;
1374
- const oldLocalVars = { [itemAlias]: instance.item, [indexAlias]: index };
1375
1445
  const newLocalVars = { [itemAlias]: newItem, [indexAlias]: index };
1376
- const oldScopedState = createScopedState(oldState, oldLocalVars, parentScope);
1446
+ // The "old" scoped state is exactly last cycle's "new" one — same global
1447
+ // snapshot (this cycle's oldState) and same item/index — already stored on the
1448
+ // instance. Reuse it instead of allocating a second proxy per row per frame
1449
+ // (createScopedState is a combat hot spot). Falls back on the first update
1450
+ // after an add, before scopedState has been recorded.
1451
+ const oldScopedState =
1452
+ instance.scopedState ||
1453
+ createScopedState(oldState, { [itemAlias]: instance.item, [indexAlias]: index }, parentScope);
1377
1454
  const newScopedState = createScopedState(newState, newLocalVars, parentScope);
1378
1455
 
1379
- refreshIterationComponentProps(instance.clonedNodes, newScopedState);
1456
+ const changedSlots = refreshIterationComponentProps(instance.clonedNodes, newScopedState);
1380
1457
 
1381
1458
  const affectedList = affected(instance.tree, oldScopedState, newScopedState);
1382
1459
  if (affectedList.length > 0) {
1383
1460
  hydrate(affectedList, newScopedState, manifest, oldScopedState);
1384
1461
  }
1385
1462
 
1386
- hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest);
1463
+ hydrateInlinedIterationComponents(instance.clonedNodes, oldScopedState, newScopedState, manifest, changedSlots);
1387
1464
 
1388
1465
  instance.item = newItem;
1389
1466
  instance.scopedState = newScopedState;
@@ -5,7 +5,6 @@
5
5
  * Uses pre-compiled batch functions from manifest generated by the compiler.
6
6
  *
7
7
  * This is the production implementation that runs compiled code generated at build time.
8
- * Based on the prototype in _vibe-compiled-iteration-batch.js
9
8
  */
10
9
 
11
10
  import { stampInstanceScopes } from './loop-scope.js';
@@ -0,0 +1,15 @@
1
+ // Marker for trusted raw-HTML output. `$.unsafe(str)` wraps a string so the
2
+ // hydrate path knows to set innerHTML instead of escaping via textContent.
3
+ // toString() returns the raw string so any non-pure use (the marker spliced
4
+ // into surrounding text) degrades to escaped literal text automatically — the
5
+ // browser escapes it when it lands in a text node.
6
+ export class RawHtml {
7
+ constructor(html) {
8
+ this.html = html == null ? '' : String(html);
9
+ }
10
+ toString() {
11
+ return this.html;
12
+ }
13
+ }
14
+
15
+ export const unsafe = (html) => new RawHtml(html);
package/runtime/utils.js CHANGED
@@ -4,6 +4,61 @@ import { THIS_PROP_REGEX } from './constants.js';
4
4
  let hashCounter = 0;
5
5
  export const hash = () => `_${hashCounter++}`;
6
6
 
7
+ // Scoped-state proxy -> its precomputed key list. The affected-walk needs the
8
+ // key set per tree node on every frame; calling Object.keys() on a scoped-state
9
+ // proxy fires its getOwnPropertyDescriptor trap for every key — a measurable
10
+ // combat hot spot. createScopedState records the keys here via
11
+ // rememberScopedKeys; ownKeysOf reads them, falling back to Object.keys for
12
+ // plain (non-scoped) states.
13
+ const scopedKeys = new WeakMap();
14
+ // Scoped-state proxy -> its local overlay (loop aliases item/index + any parent
15
+ // aliases). affected's iteration descent rebuilds a plain merged snapshot per
16
+ // instance; spreading the proxy to recover the aliases fires its traps over
17
+ // every global key. Recording the small overlay lets the descent do a cheap
18
+ // plain merge (`{...currentGlobals, ...overlay}`) instead.
19
+ const scopedOverlay = new WeakMap();
20
+ export const rememberScopedKeys = (state, keys, overlay) => {
21
+ scopedKeys.set(state, keys);
22
+ if (overlay) scopedOverlay.set(state, overlay);
23
+ return state;
24
+ };
25
+ export const ownKeysOf = (state) => scopedKeys.get(state) || Object.keys(state);
26
+ export const scopedOverlayOf = (state) => scopedOverlay.get(state);
27
+
28
+ // The live root reactive proxy, wired once at boot by index.js. Used ONLY to
29
+ // reach the root's non-enumerable helper methods (`$.unsafe`, `$.on`, …) — NOT
30
+ // for state reads.
31
+ let rootProxy = null;
32
+ export const setRootState = (proxy) => { rootProxy = proxy; };
33
+
34
+ // Resolve the `$` identifier for an expression. `$` must read from the SAME
35
+ // state object the diff cycle is evaluating — affected.js / iterate.js compare
36
+ // an expression's value against an old snapshot and a new snapshot, so binding
37
+ // `$` to the live root instead would make both reads identical and defeat
38
+ // change detection (the whole reactivity engine). So state reads stay on the
39
+ // passed `state`.
40
+ //
41
+ // The catch: the root's reserved methods (`unsafe`, `on`, `reconcile`, …) are
42
+ // non-enumerable and don't survive the plain `{...state}` snapshots, so a
43
+ // plain snapshot can't reach `$.unsafe`. Wrap such a state in a thin proxy that
44
+ // serves its own keys (snapshot reads, fully diffable) and falls back to the
45
+ // live root only for keys it lacks (the helper methods). Scoped states already
46
+ // delegate to the live root (their target is `$`), so they need no wrapper.
47
+ const dollarCache = new WeakMap();
48
+ const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
49
+ const dollarFor = (state) => {
50
+ if (!rootProxy || state === rootProxy || RESERVED_PROBE in state) return state;
51
+ let wrapped = dollarCache.get(state);
52
+ if (!wrapped) {
53
+ wrapped = new Proxy(state, {
54
+ get: (t, k) => (k in t ? t[k] : rootProxy[k]),
55
+ has: (t, k) => k in t || k in rootProxy,
56
+ });
57
+ dollarCache.set(state, wrapped);
58
+ }
59
+ return wrapped;
60
+ };
61
+
7
62
  // Function compilation cache: avoids creating new Function() for repeated expressions
8
63
  // Key: normalized expression + '\0' + state keys joined by '\0'
9
64
  const fnCache = new Map();
@@ -49,8 +104,10 @@ export const evalInScope = (expr, state, element = null) => {
49
104
  }
50
105
  }
51
106
 
52
- // Get state keys once (triggers ownKeys trap only once for Proxies)
53
- const stateKeys = Object.keys(state);
107
+ // Get state keys once. ownKeysOf reads the scoped-state proxy's precomputed
108
+ // key list instead of Object.keys() — the latter fires getOwnPropertyDescriptor
109
+ // for every key, a per-eval combat hot spot.
110
+ const stateKeys = ownKeysOf(state);
54
111
  const keyCount = stateKeys.length;
55
112
 
56
113
  // Cache lookup: expression + key signature → compiled function
@@ -113,10 +170,13 @@ export const evalInScope = (expr, state, element = null) => {
113
170
  fnCache.set(cacheKey, fn);
114
171
  }
115
172
 
116
- // Build values array matching the cached function's parameter order
173
+ // Build values array matching the cached function's parameter order. Bare
174
+ // identifiers resolve from the passed (possibly scoped) state; the `$`
175
+ // identifier resolves via dollarFor — the same state for reads (so old/new
176
+ // diffing works), with helper-method fallback to the live root.
117
177
  const values = new Array(keyCount + 1);
118
178
  for (let i = 0; i < keyCount; i++) values[i] = state[stateKeys[i]];
119
- values[keyCount] = state;
179
+ values[keyCount] = dollarFor(state);
120
180
 
121
181
  const result = fn(...values);
122
182