@ape-egg/vibe 2.3.0 → 3.0.0

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.
Files changed (57) hide show
  1. package/README.md +14 -4
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +10 -15
  6. package/llms.txt +8 -6
  7. package/package.json +19 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +312 -99
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +251 -111
  15. package/runtime/index.js +180 -71
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +69 -5
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +77 -14
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1196
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2880
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -16
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/spa.rs +0 -477
  47. package/compiler/src/compiler/state_extractor.rs +0 -263
  48. package/compiler/src/compiler/value_stamper.rs +0 -921
  49. package/compiler/src/compiler/watcher.rs +0 -1278
  50. package/compiler/src/config.rs +0 -279
  51. package/compiler/src/main.rs +0 -358
  52. package/compiler/src/parser/element.rs +0 -96
  53. package/compiler/src/parser/html.rs +0 -1004
  54. package/compiler/src/parser/mod.rs +0 -8
  55. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  56. package/runtime/scope.js +0 -50
  57. package/test-results/.last-run.json +0 -4
@@ -1,9 +1,11 @@
1
1
  import parse from './parse.js';
2
- import affected from './affected.js';
2
+ import affected, { nodeSubscriberOf } from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
- import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
- import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys } from './utils.js';
4
+ import { resolvePath, getItemKey, computeDiff, iterPropWrappersOf, bumpIterPropGeneration } from './iteration-utils.js';
5
+ import { resolveThisPath, evalInScope, findComponentIdForElement, rememberScopedKeys, overlayKeysOf } from './utils.js';
6
+ import { recordRead, beginTracking, endTracking, pruneDisconnected, markAlways } from './tracking.js';
6
7
  import { managedNodes, extractDependencies } from './conditionals.js';
8
+ import { liveNode, isOutgoing } from './staging.js';
7
9
  import { stampInstanceScopes } from './loop-scope.js';
8
10
  import {
9
11
  BINDING_REGEX,
@@ -66,13 +68,13 @@ const hasComponentSrc = (templateEl) =>
66
68
  const hasInlinedComponentScript = (templateEl) =>
67
69
  !!templateEl.querySelector?.('script[type="vibe-module"]');
68
70
 
69
- // `__vibeForceClonePath` is a debug/test escape hatch — set it on globalThis to
71
+ // `__vibe.forceClonePath` is a debug/test escape hatch — set it to
70
72
  // route every iteration through the clone+hydrate path, even templates that
71
73
  // would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
72
74
  // test harness so the same templates can be rendered through both paths and
73
75
  // compared. Not part of the public API.
74
76
  const canUseBatchRender = (template) =>
75
- !globalThis.__vibeForceClonePath &&
77
+ !globalThis.__vibe?.forceClonePath &&
76
78
  !hasNestedStructures(template) &&
77
79
  template.element.children.length <= 1 &&
78
80
  !hasComponentSrc(template.element) &&
@@ -163,7 +165,24 @@ const isValueStyleAttr = (attrName) =>
163
165
  // to render identically. New binding forms must update both paths in lockstep
164
166
  // or the equivalence harness in tests/e2e/batch-vs-clone-equivalence.spec.js
165
167
  // will catch the divergence.
166
- const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) => {
168
+ // Root-position `_cN` component references inside a binding expression
169
+ // string literals are consumed whole and left untouched (an `_cN` inside a
170
+ // quoted string is content, not a reference — same discipline as
171
+ // compileExpression's alternation in utils.js; the two renderers are
172
+ // lockstep-equivalent by contract). Already-canonical `$['_cN']` forms and
173
+ // property accesses stay untouched via the lookbehind.
174
+ const CID_ROOT_REGEX =
175
+ /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(?<![\w$.])_c(\d+)\b/g;
176
+
177
+ export const canonicalizeComponentIds = (expr) =>
178
+ expr.replace(CID_ROOT_REGEX, (m, literal, id) => (literal !== undefined ? literal : `$['_c${id}']`));
179
+
180
+ // Name-binding emission: resolve the expression under the `_e` guard, emit
181
+ // ` value=""` when truthy, nothing otherwise.
182
+ const nameBindingEmit = (exprSrc) =>
183
+ '${(()=>{const _v=_e(()=>(' + exprSrc + '));return _v?\' \'+_v+\'=""\':\'\';})()}';
184
+
185
+ export const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) => {
167
186
  // Walk a clone of the template DOM so we can stash a transient
168
187
  // `data-vibe-batch` marker on every element with DOM-property bindings
169
188
  // without polluting the cached template that subsequent renders read.
@@ -225,6 +244,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
225
244
  );
226
245
  }
227
246
 
247
+ // Compiler-stamped component references (`_cN.x` in SPA fragments) resolve
248
+ // through `$` at call time — the mirror of evalInScope's id canonicalization.
249
+ // Baked as bare identifiers they'd be a ReferenceError whenever the id is
250
+ // missing from the compile-time state-key snapshot (a fragment iteration can
251
+ // hydrate before its component script registers), and a state key that
252
+ // arrives later would never be picked up by the cached batch function.
253
+ BINDING_REGEX.lastIndex = 0;
254
+ templateHtml = templateHtml.replace(BINDING_REGEX, (_, expr) =>
255
+ '@[' + canonicalizeComponentIds(expr) + ']',
256
+ );
257
+
228
258
  const escaped = templateHtml
229
259
  .replace(/\\/g, '\\\\')
230
260
  .replace(/`/g, '\\`')
@@ -255,13 +285,13 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
255
285
  code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
256
286
  let decExpr = decodeEntities(expr);
257
287
  if (decExpr.includes('[') || decExpr.includes('(')) {
258
- return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
288
+ return nameBindingEmit(decExpr);
259
289
  }
260
290
  const dot = decExpr.indexOf('.');
261
291
  if (dot === -1) {
262
292
  const ci = stateKeys.find((k) => k.toLowerCase() === decExpr.toLowerCase());
263
293
  if (ci) decExpr = ci;
264
- return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
294
+ return nameBindingEmit(decExpr);
265
295
  }
266
296
  const head = decExpr.slice(0, dot);
267
297
  const tail = decExpr.slice(dot + 1).split('.');
@@ -269,10 +299,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
269
299
  const ciHead = knownNames.find((k) => k.toLowerCase() === head.toLowerCase()) || head;
270
300
  const tailJSON = JSON.stringify(tail);
271
301
  needsCiWalker = true;
272
- return (
273
- '${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
274
- ');return _v?\' \'+_v+\'=""\':\'\';})()}'
275
- );
302
+ return nameBindingEmit('_walkCi(' + ciHead + ',' + tailJSON + ')');
276
303
  }));
277
304
 
278
305
  // Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
@@ -283,14 +310,14 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
283
310
  code = code.replace(BATCH_ATTR_BINDING_REGEX, (_, _ws, attrName, expr) => {
284
311
  const decExpr = decodeEntities(expr);
285
312
  if (DOM_PROPERTIES.includes(attrName) || isValueStyleAttr(attrName)) {
286
- return ' ' + attrName + '="${' + decExpr + '}"';
313
+ return ' ' + attrName + '="${_e(()=>(' + decExpr + '))}"';
287
314
  }
288
- return '${(' + decExpr + ') ? \' ' + attrName + '=""\' : \'\'}';
315
+ return '${_e(()=>(' + decExpr + ')) ? \' ' + attrName + '=""\' : \'\'}';
289
316
  });
290
317
 
291
318
  // Remaining @[…] markers — text content and partial-binding attribute
292
319
  // values like `href="/items/@[id]/edit"`.
293
- code = code.replace(BINDING_REGEX, (_, expr) => '${' + decodeEntities(expr) + '}');
320
+ code = code.replace(BINDING_REGEX, (_, expr) => '${_e(()=>(' + decodeEntities(expr) + '))}');
294
321
 
295
322
  // `$` is supplied as the last parameter so component-scoped expressions like
296
323
  // `$['_c0'].chosen` (produced by the this.X rewrite above) can resolve
@@ -298,6 +325,12 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
298
325
  // `_walkCi` is only emitted when at least one dotted-path name binding was
299
326
  // rewritten above — keeps the function body free of dead code for the
300
327
  // common no-name-binding case.
328
+ // Per-expression failure contract, mirroring evalInScope: an expression
329
+ // that can't evaluate yet (component state not registered, alias mid-swap)
330
+ // renders '' and the next flush corrects it — it must never throw out of
331
+ // the batch loop and abort every row after it. Non-throwing values pass
332
+ // through untouched, so template-literal coercion stays identical.
333
+ const evalGuardSrc = "const _e = (f) => { try { return f(); } catch { return ''; } };";
301
334
  const ciWalkerSrc = needsCiWalker
302
335
  ? `const _walkCi = (cur, segs) => {
303
336
  for (let i = 0; i < segs.length; i++) {
@@ -317,6 +350,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
317
350
  ...stateKeys,
318
351
  '$',
319
352
  `
353
+ ${evalGuardSrc}
320
354
  ${ciWalkerSrc}
321
355
  let html = '';
322
356
  const len = arr.length;
@@ -338,29 +372,28 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
338
372
  // is detached (see releaseOrphanedIterationProps).
339
373
  //
340
374
  // The name is intentionally all-lowercase. resolveIterationComponentProps
341
- // injects `@[window.__vibeiterprops._pN]` into the component's bindings, and
375
+ // injects `@[window.__vibe.iterProps._pN]` into the component's bindings, and
342
376
  // prop substitution carries that accessor into the template's own bindings —
343
377
  // including name-bindings (`<icon @[props.element]>`). HTML lowercases
344
378
  // attribute names, so a camelCase accessor would arrive at hydrate as
345
- // `window.__vibeiterprops` and resolve to undefined, silently dropping the
379
+ // `window.__vibe.iterProps` and resolve to undefined, silently dropping the
346
380
  // attribute. Keeping the global lowercase makes it survive that normalization.
347
381
  let __vibeIterPropCounter = 0;
348
382
  const ensureIterPropsRegistry = () => {
349
- if (!window.__vibeiterprops) window.__vibeiterprops = {};
350
- return window.__vibeiterprops;
383
+ return ((window.__vibe ??= {}).iterProps ??= {});
351
384
  };
352
385
 
353
386
  // Walk a removed subtree and free any iteration-prop registry slots stashed
354
387
  // on `<component>` elements inside it. Called from the mutation-observer
355
388
  // cleanup path after DOM detachment.
356
389
  export const releaseOrphanedIterationProps = (nodes) => {
357
- if (!window.__vibeiterprops) return;
390
+ if (!window.__vibe?.iterProps) return;
358
391
  for (const node of nodes) {
359
392
  if (node.nodeType !== 1) continue;
360
393
  const free = (el) => {
361
394
  const ids = el._vibeIterPropIds;
362
395
  if (!ids) return;
363
- for (const id of ids) delete window.__vibeiterprops[id];
396
+ for (const id of ids) delete window.__vibe.iterProps[id];
364
397
  el._vibeIterPropIds = null;
365
398
  };
366
399
  free(node);
@@ -404,18 +437,19 @@ const resolveSlotContentBindings = (el, scopedState, aliases) => {
404
437
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: null, expr });
405
438
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
406
439
  }
407
- return `@[window.__vibeiterprops.${id}]`;
440
+ return `@[window.__vibe.iterProps.${id}]`;
408
441
  });
409
442
  if (idByExpr.size) {
410
443
  el._vibeSlotContent = rewritten;
411
444
  el.setAttribute('data-vibe-iter-prop', '');
445
+ bumpIterPropGeneration();
412
446
  }
413
447
  };
414
448
 
415
449
  // For <component src> elements inside an iteration instance, evaluate any
416
450
  // `@[expr]` attribute bindings against the iteration's scoped state and route
417
451
  // every resolved value through the global iteration-prop registry. The prop
418
- // attribute becomes `@[window.__vibeiterprops._pN]` — a live binding into the
452
+ // attribute becomes `@[window.__vibe.iterProps._pN]` — a live binding into the
419
453
  // registry slot — for both primitives and objects. The original expression is
420
454
  // stashed on the element so the iteration's update path can re-evaluate it
421
455
  // against the new scope and refresh the slot, propagating the change into the
@@ -460,6 +494,7 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
460
494
  (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
461
495
  if (!usesLocalScope) {
462
496
  el.setAttribute('data-vibe-iter-prop', '');
497
+ bumpIterPropGeneration();
463
498
  el._vibeIterPropExprs = el._vibeIterPropExprs || [];
464
499
  continue;
465
500
  }
@@ -469,8 +504,9 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
469
504
  const registry = ensureIterPropsRegistry();
470
505
  const id = `_p${__vibeIterPropCounter++}`;
471
506
  registry[id] = value;
472
- el.setAttribute(attr.name, `@[window.__vibeiterprops.${id}]`);
507
+ el.setAttribute(attr.name, `@[window.__vibe.iterProps.${id}]`);
473
508
  el.setAttribute('data-vibe-iter-prop', '');
509
+ bumpIterPropGeneration();
474
510
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
475
511
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
476
512
  } catch {
@@ -482,18 +518,6 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
482
518
  }
483
519
  };
484
520
 
485
- // Resolve a clonedNodes entry to its live counterpart. processComponent's
486
- // `el.replaceWith(newWrapper)` detaches the original `<component src>` and
487
- // installs a `<component>` post-process wrapper in its place — but the
488
- // iteration's `instance.clonedNodes` still points at the original. The
489
- // `_vibeReplacedBy` chain (set in component.js right before the swap) gives
490
- // us a single pointer to the live wrapper.
491
- const liveCloneNode = (node) => {
492
- let cur = node;
493
- while (cur && cur._vibeReplacedBy) cur = cur._vibeReplacedBy;
494
- return cur;
495
- };
496
-
497
521
  // Walk a row's live cloned nodes and invoke `fn` for every inlined-component
498
522
  // wrapper carrying `marker` — the resolved node itself plus any
499
523
  // `[data-vibe-iter-prop]` descendant that carries it. The prop-refresh and
@@ -503,11 +527,11 @@ const liveCloneNode = (node) => {
503
527
  // marker matches the historical "take all, skip those without exprs" behavior.)
504
528
  const forEachIterWrapper = (clonedNodes, marker, fn) => {
505
529
  for (let n = 0; n < clonedNodes.length; n++) {
506
- const node = liveCloneNode(clonedNodes[n]);
530
+ const node = liveNode(clonedNodes[n]);
507
531
  if (!node || node.nodeType !== 1) continue;
508
532
  if (node[marker]) fn(node);
509
- const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
510
- if (found) for (let i = 0; i < found.length; i++) {
533
+ const found = iterPropWrappersOf(node);
534
+ for (let i = 0; i < found.length; i++) {
511
535
  if (found[i][marker]) fn(found[i]);
512
536
  }
513
537
  }
@@ -555,7 +579,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
555
579
 
556
580
  // Walk an inlined component's parsed tree and force `updateIteration` on any
557
581
  // iteration node whose arrayPath resolves through the iteration-prop registry
558
- // (`window.__vibeiterprops._pN`). The registry slot was just refreshed in
582
+ // (`window.__vibe.iterProps._pN`). The registry slot was just refreshed in
559
583
  // place by `refreshIterationComponentProps`, so `affected()` can't notice
560
584
  // the change — both old/new evaluations of the path read the same updated
561
585
  // value. `updateIteration` is the only place equipped to diff against
@@ -564,7 +588,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
564
588
  // in sync. Without this, an `<inner-component>` whose template iterates over
565
589
  // an array prop stays frozen on its initial-render items when the prop's
566
590
  // contents change.
567
- const REGISTRY_SLOT_REGEX = /__vibeiterprops\.(_p\d+)/;
591
+ const REGISTRY_SLOT_REGEX = /__vibe\.iterProps\.(_p\d+)/;
568
592
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
569
593
  if (!tree) return;
570
594
  if (tree.type === 'iteration') {
@@ -705,6 +729,11 @@ const renderBatch = (iterationNode, array, state, parent, endComment, parentScop
705
729
  // unrelated state change produces identical HTML (see updateIteration).
706
730
  iterationNode.runtime.lastBatchHtml = html;
707
731
 
732
+ // Batch rows close over every state key (stateKeys = Object.keys(state) at
733
+ // compile time) — "re-render on any change" is their honest dependency
734
+ // set; the identical-HTML guard above absorbs the no-ops (walk parity).
735
+ markAlways(nodeSubscriberOf(iterationNode, 'iteration'));
736
+
708
737
  batchParseTemplate.innerHTML = html;
709
738
  const frag = batchParseTemplate.content;
710
739
  const kids = frag.children;
@@ -1001,6 +1030,11 @@ export const createScopedState = (globalState, localVars, parentScope = {}) => {
1001
1030
  get(target, prop) {
1002
1031
  if (prop in localVars) return localVars[prop];
1003
1032
  if (prop in parentScope) return parentScope[prop];
1033
+ // Global fallthrough is a root-key read; alias hits above are the
1034
+ // row's own scope (owned by its iteration's diff) and never record.
1035
+ // When the target is the live root the proxy trap records too — the
1036
+ // window's read Set dedupes.
1037
+ recordRead(prop);
1004
1038
  return Reflect.get(target, prop);
1005
1039
  },
1006
1040
 
@@ -1046,8 +1080,10 @@ export const renderAllIterations = (tree, state, manifest, parentScope = {}) =>
1046
1080
  return 1;
1047
1081
  }
1048
1082
 
1049
- // Recursively render iterations in child nodes
1050
- if (tree.children) {
1083
+ // Recursively render iterations in child nodes — except under an outgoing
1084
+ // wrapper (reactive-src remount in flight): its subtree is frozen until
1085
+ // the swap replaces it.
1086
+ if (tree.children && !isOutgoing(tree.element)) {
1051
1087
  for (const key in tree.children) {
1052
1088
  const child = tree.children[key];
1053
1089
  if (child && typeof child === 'object') {
@@ -1114,7 +1150,12 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
1114
1150
  // and inline literals (['a', 'b']). Falls back to resolvePath for
1115
1151
  // simple paths that evalInScope might miss in scoped contexts.
1116
1152
  const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
1153
+ // First render doubles as the iteration's subscription registration.
1154
+ const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
1155
+ trackSub.lastScope = state;
1156
+ beginTracking(trackSub, overlayKeysOf(state));
1117
1157
  const array = evalInScope(resolvedExpr, state, startComment.parentElement) ?? resolvePath(state, resolvedExpr);
1158
+ endTracking();
1118
1159
  if (!Array.isArray(array) || array.length === 0) {
1119
1160
  iterationNode.runtime.instances = [];
1120
1161
  return;
@@ -1124,6 +1165,9 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
1124
1165
  if (compiled.canUseCompiled(iterationNode)) {
1125
1166
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
1126
1167
  if (compiled.renderCompiled(iterationNode, array, state, compiledMeta, parent, endComment)) {
1168
+ // Compiled rows close over every state key — "any change" is their
1169
+ // honest dependency set (walk parity for treeless instances).
1170
+ markAlways(trackSub);
1127
1171
  // Mark as rendered
1128
1172
  startComment.__vibeRendered = true;
1129
1173
  startComment.__vibeIterationRuntime = iterationNode.runtime;
@@ -1163,7 +1207,7 @@ const evalKeyForItem = (iterationNode, item, index, state, parentScope) => {
1163
1207
  // once per iteration block, so console doesn't drown.
1164
1208
  const warnIndexCoupledKey = (iterationNode) => {
1165
1209
  if (iterationNode.runtime.warnedIndexCoupled) return;
1166
- if (!globalThis.__vibeDebug) return;
1210
+ if (!globalThis.__vibe?.debug) return;
1167
1211
  iterationNode.runtime.warnedIndexCoupled = true;
1168
1212
  const arrayPath = iterationNode.meta.arrayPath;
1169
1213
  console.warn(
@@ -1180,7 +1224,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1180
1224
  const resolvedExpr = resolveThisPath(arrayPath, startComment.parentElement);
1181
1225
 
1182
1226
  const stateOldArray = evalInScope(resolvedExpr, oldState, startComment.parentElement) ?? resolvePath(oldState, resolvedExpr) ?? [];
1227
+ // New-side eval re-records the iteration's subscription (self-healing).
1228
+ const trackSub = nodeSubscriberOf(iterationNode, 'iteration');
1229
+ trackSub.lastScope = newState;
1230
+ beginTracking(trackSub, overlayKeysOf(newState));
1183
1231
  const newArray = evalInScope(resolvedExpr, newState, startComment.parentElement) ?? resolvePath(newState, resolvedExpr) ?? [];
1232
+ endTracking();
1184
1233
 
1185
1234
  // Use instances (what's actually rendered) as ground truth for old array
1186
1235
  // whenever the only signal of change is a registry side-effect, or when
@@ -1191,7 +1240,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1191
1240
  // mutation was a registry slot rewrite. The previously rendered
1192
1241
  // items are the only honest record of what was there before.
1193
1242
  // 2. stateOldArray === newArray — the iteration's arrayPath resolves
1194
- // directly to a registry slot (`window.__vibeiterprops._pN`); the
1243
+ // directly to a registry slot (`window.__vibe.iterProps._pN`); the
1195
1244
  // slot was swapped in place, so both reads return the same NEW
1196
1245
  // array.
1197
1246
  // 3. length mismatch — oldState predates the current render.
@@ -1223,6 +1272,8 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1223
1272
  endComment,
1224
1273
  )
1225
1274
  ) {
1275
+ // Still treeless — keep the honest any-change subscription.
1276
+ markAlways(trackSub);
1226
1277
  return;
1227
1278
  }
1228
1279
  // Fall through to runtime path if compiled failed
@@ -1242,6 +1293,10 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1242
1293
  // skip this — they take the diff path below.
1243
1294
  const hasTreelessInstances = instances.length > 0 && !instances[0].tree;
1244
1295
  if (hasTreelessInstances && oldState !== newState) {
1296
+ // Treeless rows stay on the any-change subscription. If bulkReplace
1297
+ // below clone-renders trees instead, the next new-side eval re-records
1298
+ // real deps (self-healing).
1299
+ markAlways(trackSub);
1245
1300
  // affected.js conservatively flags a treeless (batch-rendered) iteration on
1246
1301
  // ANY state change, since it can't walk per-row trees to see which bindings
1247
1302
  // actually depend on what changed. Before tearing down and recreating every
@@ -1385,8 +1440,27 @@ const releaseRemovedSubtrees = (removedRoots, manifest) => {
1385
1440
  }
1386
1441
 
1387
1442
  // Parsed tree: delete each removed root's node — its whole subtree (nested
1388
- // conditionals/iterations and their branch templates) goes with it.
1389
- if (tree) pruneTreeNodes(tree, removedEls);
1443
+ // conditionals/iterations and their branch templates) goes with it. Prune
1444
+ // by CONTAINMENT, not just root identity: a row's inlined component trees
1445
+ // are also linked under OTHER ancestors' children (the dual linkage the
1446
+ // affected-walk dedupe documents), keyed by inner wrapper elements that are
1447
+ // never themselves removed roots. Left in place, every later walk descends
1448
+ // those detached trees — re-hydrating dead DOM and (since subscriptions)
1449
+ // re-registering its subscribers after teardown pruned them, pinning the
1450
+ // whole removed subtree forever.
1451
+ const roots = [...removedEls];
1452
+ if (tree) pruneTreeNodes(tree, removedEls, roots);
1453
+
1454
+ // The removed rows' subscribers (bindings, nested conditionals/iterations)
1455
+ // are anchored to nodes that just left the document.
1456
+ pruneDisconnected();
1457
+ };
1458
+
1459
+ const underRemovedRoot = (el, roots) => {
1460
+ for (let i = 0; i < roots.length; i++) {
1461
+ if (roots[i] !== el && roots[i].contains?.(el)) return true;
1462
+ }
1463
+ return false;
1390
1464
  };
1391
1465
 
1392
1466
  // True when `key` is, or is a descendant of, any path in `removedPaths`.
@@ -1398,14 +1472,15 @@ const pathUnderRemoved = (key, removedPaths) => {
1398
1472
  return false;
1399
1473
  };
1400
1474
 
1401
- const pruneTreeNodes = (node, removedEls) => {
1475
+ const pruneTreeNodes = (node, removedEls, roots) => {
1402
1476
  const children = node.children;
1403
1477
  if (!children) return;
1404
1478
  for (const key in children) {
1405
1479
  const child = children[key];
1406
1480
  if (!child) continue;
1407
- if (removedEls.has(child.element)) delete children[key];
1408
- else pruneTreeNodes(child, removedEls);
1481
+ const el = child.element ?? child.textNode;
1482
+ if (el && (removedEls.has(el) || underRemovedRoot(el, roots))) delete children[key];
1483
+ else pruneTreeNodes(child, removedEls, roots);
1409
1484
  }
1410
1485
  };
1411
1486
 
@@ -35,8 +35,11 @@ export const cloneTemplate = (templateElement) => {
35
35
  return templateElement.cloneNode(true);
36
36
  };
37
37
 
38
- // Find matching <!-- /each --> comment with depth tracking
39
- export const findEndComment = (nodes, startIndex) => {
38
+ // Find matching <!-- /each --> comment with depth tracking. The index form
39
+ // returns -1 when unmatched parse.js probes with it to tell an authored
40
+ // (but malformed) directive from a prose comment that merely starts with
41
+ // "each".
42
+ export const findEndCommentIndex = (nodes, startIndex) => {
40
43
  let depth = 1;
41
44
  for (let i = startIndex; i < nodes.length; i++) {
42
45
  if (nodes[i].nodeName === '#comment') {
@@ -51,14 +54,25 @@ export const findEndComment = (nodes, startIndex) => {
51
54
  }
52
55
  }
53
56
  }
54
- throw new Error('Unmatched <!-- each --> comment: missing <!-- /each -->');
57
+ return -1;
55
58
  };
56
59
 
57
- // Find matching <!-- /if --> and optional <!-- else --> with depth tracking
58
- // Returns { elseIndex: number | null, endIndex: number }
60
+ export const findEndComment = (nodes, startIndex) => {
61
+ const i = findEndCommentIndex(nodes, startIndex);
62
+ if (i === -1) throw new Error('Unmatched <!-- each --> comment: missing <!-- /each -->');
63
+ return i;
64
+ };
65
+
66
+ // Find matching <!-- /if --> and optional <!-- else --> / <!-- else if -->
67
+ // with depth tracking. Returns { elseIndex, endIndex, elseText } — elseText
68
+ // is the boundary comment's trimmed text ('else' or 'else if <expr>'), so
69
+ // parse.js can desugar a chain: only the FIRST boundary at depth 1 is
70
+ // captured; the rest of the chain lives inside the false-branch span and
71
+ // desugars recursively into nested conditionals.
59
72
  export const findConditionalEnd = (nodes, startIndex) => {
60
73
  let depth = 1;
61
74
  let elseIndex = null;
75
+ let elseText = null;
62
76
 
63
77
  for (let i = startIndex; i < nodes.length; i++) {
64
78
  if (nodes[i].nodeName === '#comment') {
@@ -68,15 +82,20 @@ export const findConditionalEnd = (nodes, startIndex) => {
68
82
  if (CONDITIONAL_START_REGEX.test(text)) {
69
83
  depth++;
70
84
  }
71
- // Check for else at current depth
72
- else if (text === 'else' && depth === 1 && elseIndex === null) {
85
+ // Check for else / else-if at current depth
86
+ else if (
87
+ depth === 1 &&
88
+ elseIndex === null &&
89
+ (text === 'else' || /^else\s+if\s+.+/.test(text))
90
+ ) {
73
91
  elseIndex = i;
92
+ elseText = text;
74
93
  }
75
94
  // Check for /if
76
95
  else if (text === '/if') {
77
96
  depth--;
78
97
  if (depth === 0) {
79
- return { elseIndex, endIndex: i };
98
+ return { elseIndex, endIndex: i, elseText };
80
99
  }
81
100
  }
82
101
  }
@@ -196,6 +215,38 @@ export const longestCommonSubsequence = (arr1, arr2) => {
196
215
  return lcs;
197
216
  };
198
217
 
218
+ // The affected walk and the iteration update path both ask "which
219
+ // [data-vibe-iter-prop] wrappers live under this row node?" for every row on
220
+ // every flush — re-querying each time dominated flush cost on iteration-heavy
221
+ // pages (~24k querySelectorAll calls per flush on the game's armory, nearly all
222
+ // returning nothing). The wrapper set only changes when a mount path stamps the
223
+ // attribute, so each stamp site bumps a global generation and the query result
224
+ // is cached per node against it. A cache hit still prunes wrappers whose
225
+ // subtree was torn down since (nested each/if removals keep the row node but
226
+ // drop descendants) — additions always arrive through a stamp ⇒ a bump ⇒ a
227
+ // fresh query.
228
+ let iterPropGeneration = 0;
229
+
230
+ export const bumpIterPropGeneration = () => {
231
+ iterPropGeneration++;
232
+ };
233
+
234
+ export const iterPropWrappersOf = (node) => {
235
+ const cached = node._vibeIterPropWrappers;
236
+ if (cached && cached.gen === iterPropGeneration) {
237
+ const list = cached.list;
238
+ for (let i = list.length - 1; i >= 0; i--) {
239
+ if (!node.contains(list[i])) list.splice(i, 1);
240
+ }
241
+ return list;
242
+ }
243
+ const list = [];
244
+ const found = node.querySelectorAll?.('[data-vibe-iter-prop]');
245
+ if (found) for (let i = 0; i < found.length; i++) list.push(found[i]);
246
+ node._vibeIterPropWrappers = { gen: iterPropGeneration, list };
247
+ return list;
248
+ };
249
+
199
250
  // Generate unique key for array items.
200
251
  // `customKey` (when defined and not null) wins over every default heuristic —
201
252
  // the developer has declared identity explicitly via `<!-- each xs as x (expr) -->`.
@@ -1,6 +1,81 @@
1
+ // The manifest is the one-way dotPath→element map the runtime hangs DOM
2
+ // bookkeeping on. Three hot paths need the REVERSE direction (element→path):
3
+ // conditional branch mounts and the mutation observer's add/remove tracking.
4
+ // Scanning Object.entries per lookup is O(manifest) per node — quadratic as
5
+ // pages grow, and the dominant cost of mounting conditional-heavy pages. A
6
+ // WeakMap rides on the manifest object (non-enumerable, like __live/__tree)
7
+ // and is maintained by every write; lookups validate against the forward map
8
+ // so a deleted entry can never resolve stale.
9
+ const indexOf = (manifest) => {
10
+ if (!manifest.__paths) {
11
+ Object.defineProperty(manifest, '__paths', {
12
+ value: new WeakMap(),
13
+ enumerable: false,
14
+ configurable: true,
15
+ });
16
+ }
17
+ return manifest.__paths;
18
+ };
19
+
20
+ // Single write point: forward map + reverse index together. Non-object
21
+ // "elements" (text nodes stored as strings, null placeholders) can't be
22
+ // WeakMap keys and are never reverse-looked-up — forward map only. An
23
+ // element can legitimately hold MORE than one path (its own node plus an
24
+ // inlined-slot alias, branch-graft re-registrations); the index keeps every
25
+ // registered path shortest-first, so lookups answer with the subtree ROOT —
26
+ // removal-time pruning must sweep the whole region, not a deeper alias.
27
+ export const setManifestEntry = (manifest, path, element) => {
28
+ manifest[path] = element;
29
+ if (element !== null && typeof element === 'object') {
30
+ const index = indexOf(manifest);
31
+ const paths = index.get(element);
32
+ if (!paths) {
33
+ index.set(element, [path]);
34
+ } else if (!paths.includes(path)) {
35
+ let at = paths.length;
36
+ while (at > 0 && paths[at - 1].length > path.length) at--;
37
+ paths.splice(at, 0, path);
38
+ }
39
+ }
40
+ };
41
+
42
+ // O(paths-per-element) reverse lookup, validated: entries whose forward
43
+ // mapping was deleted (branch unmounts, subtree pruning) compact away; the
44
+ // first path the forward map still agrees with — the shortest live one —
45
+ // wins. Null when none survive.
46
+ export const manifestPathOf = (manifest, element) => {
47
+ if (element === null || typeof element !== 'object') return null;
48
+ const paths = manifest.__paths?.get(element);
49
+ if (!paths) return null;
50
+ for (let i = 0; i < paths.length; i++) {
51
+ if (manifest[paths[i]] === element) {
52
+ if (i > 0) paths.splice(0, i);
53
+ return paths[0];
54
+ }
55
+ }
56
+ manifest.__paths.delete(element);
57
+ return null;
58
+ };
59
+
60
+ // Removal counterpart of addToManifest (index.js): drop the root entry and
61
+ // every entry under its dot-scope. A PREFIX SWEEP over the manifest keys, not
62
+ // a tree walk — branch mounts register alias paths a tree walk can't reach,
63
+ // and the sweep stays correct through any tree/manifest drift. Deleting only
64
+ // the root entry leaked the rest: an SPA outlet swap left ~4,400
65
+ // detached-element entries behind per navigation, pinning the DOM of every
66
+ // page ever visited. The manifest stays bounded, so O(manifest) per removal
67
+ // batch is cheap. An empty root path is never a legitimate removal target.
68
+ export const removeManifestSubtree = (manifest, dotPath) => {
69
+ if (!dotPath) return;
70
+ delete manifest[dotPath];
71
+ const prefix = dotPath + '.';
72
+ for (const key in manifest) {
73
+ if (key.startsWith(prefix)) delete manifest[key];
74
+ }
75
+ };
76
+
1
77
  const recursive = (tree, results, tagChain) => {
2
- const path = tagChain.join('.');
3
- results[path] = tree.element;
78
+ setManifestEntry(results, tagChain.join('.'), tree.element);
4
79
 
5
80
  if (tree.children && Object.keys(tree.children).length > 0) {
6
81
  Object.keys(tree.children).forEach((tag) => {