@ape-egg/vibe 2.1.16 → 2.1.18

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.1.18] - 2026-06-22
4
+
5
+ ### Fixed
6
+
7
+ - **Loop-scoped bindings in slot content passed to a component inside an iteration didn't resolve** (`runtime/iterate.js`) — content projected into a `<component src>` is captured raw (`_vibeSlotContent`) and inlined only when `processComponent` runs, by which point the row's iteration scope is gone. So a `@[...]` in that slot content rooted in a loop alias (`item`/`index`/outer) or `this` couldn't resolve later: value bindings rendered `undefined` and name-bindings (`<icon @[row.icon]>`) never set their attribute. The new `resolveSlotContentBindings` pre-resolves those bindings into registry-backed global refs — the same snapshot mechanism the component's own prop attributes use — before the scope is lost, so the inlined slot hydrates against the right values and the row's update path refreshes them in place (the wrapper inherits `_vibeIterPropExprs` / `data-vibe-iter-prop`, so `refreshIterationComponentProps` re-evaluates them on each item change). Globals-only bindings are left raw and resolve through the normal reactive path. Tests: `e2e-runtime/slot-name-binding-loop.html`, `tests/e2e/slot-name-binding.spec.js`.
8
+
9
+ ## [2.1.17] - 2026-06-22
10
+
11
+ ### Fixed
12
+
13
+ - **Compiled inlined components inside an iteration shared one local-state bucket across all rows** (`runtime/iterate.js`) — in compiled mode each inlined component carries a fixed `data-vibe-component-id` (`_cN`) with its `@[this.x]` bindings and `$.this.x` handlers stamped to that id. An iteration clones its template once per row, so every row reused the same baked id — and therefore the same `component({...})` state bucket — and opening one row's ability drawer opened them all (two brawler slots both resolving to `_c2.open`). `initializeBlock` now isolates components per row: `isolateInlinedComponentIds` remaps every baked `_cN` in the clone to a fresh `generateComponentId()` and rewrites the `_cN.prop` references in attributes and text (boundary-anchored so `_c2` never matches inside `_c20`), then runs the row's inlined `vibe-module` setup scripts so each registers isolated state under its fresh id — mirroring the conditional-branch path. Batch render is also disabled for templates carrying an inlined component script (it emits one shared HTML string per row, which would duplicate the baked id), routing them through the clone-and-isolate path instead. Runtime mode is unaffected: there are no baked ids there — components are still `<component src>`.
14
+
3
15
  ## [2.1.16] - 2026-06-22
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "2.1.16",
3
+ "version": "2.1.18",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -16,6 +16,8 @@ import {
16
16
  // Pre-compiled iteration optimization (production)
17
17
  import * as compiled from './pre-compiled-iterations.js';
18
18
 
19
+ import { generateComponentId, executeCompiledComponentScriptsIn } from './component.js';
20
+
19
21
  // Runtime batch-render helpers for full-replacement of simple templates.
20
22
  // Build an HTML string via template-literal compilation, then parse once —
21
23
  // avoids per-item clone/parse/hydrate in the hot path.
@@ -56,6 +58,14 @@ const hasNestedStructures = (tree) => {
56
58
  const hasComponentSrc = (templateEl) =>
57
59
  !!templateEl.querySelector?.('component[src], div.component[src]');
58
60
 
61
+ // A compiled inlined component with its own setup script carries component-local
62
+ // state (`component({...})` → `$._cN`). Batch render emits one shared HTML string
63
+ // per row, which would duplicate the baked `_cN` id across rows and collapse
64
+ // their state into one bucket. Route these through the clone path, where
65
+ // initializeBlock isolates each row's component ids.
66
+ const hasInlinedComponentScript = (templateEl) =>
67
+ !!templateEl.querySelector?.('script[type="vibe-module"]');
68
+
59
69
  // `__vibeForceClonePath` is a debug/test escape hatch — set it on globalThis to
60
70
  // route every iteration through the clone+hydrate path, even templates that
61
71
  // would otherwise qualify for batch. Used by the batch-vs-clone-equivalence
@@ -65,7 +75,8 @@ const canUseBatchRender = (template) =>
65
75
  !globalThis.__vibeForceClonePath &&
66
76
  !hasNestedStructures(template) &&
67
77
  template.element.children.length <= 1 &&
68
- !hasComponentSrc(template.element);
78
+ !hasComponentSrc(template.element) &&
79
+ !hasInlinedComponentScript(template.element);
69
80
 
70
81
  // Patterns used by compileBatchFn to recognize bindings in attribute-name and
71
82
  // attribute-value positions. The inner alternation mirrors BINDING_INNER from
@@ -357,6 +368,50 @@ export const releaseOrphanedIterationProps = (nodes) => {
357
368
  }
358
369
  };
359
370
 
371
+ // Slot content projected into a <component src> is captured raw (`_vibeSlotContent`)
372
+ // and inlined only when processComponent runs — by which point the row's iteration
373
+ // scope is gone. Any `@[...]` in that content rooted in a loop alias (item/index/
374
+ // outer) or `this` therefore can't resolve later: value bindings render undefined
375
+ // and name-bindings (`<icon @[row.icon]>`) never set their attribute. Pre-resolve
376
+ // those into registry-backed global refs here — the same snapshot mechanism the
377
+ // component's own prop attributes use — so the inlined slot hydrates against the
378
+ // right values and the row's update path refreshes them in place (the wrapper
379
+ // inherits `_vibeIterPropExprs`/`data-vibe-iter-prop`, so refreshIterationComponentProps
380
+ // re-evaluates them on each item change). Globals-only bindings are left raw; they
381
+ // resolve through the normal reactive path against the inlined component's scope.
382
+ const resolveSlotContentBindings = (el, scopedState, aliases) => {
383
+ const html = el._vibeSlotContent;
384
+ if (!html || !html.includes('@[')) return;
385
+ const registry = ensureIterPropsRegistry();
386
+ const idByExpr = new Map();
387
+ const rewritten = html.replace(BINDING_REGEX, (whole, expr) => {
388
+ const usesLocalScope =
389
+ /\bthis\b/.test(expr) ||
390
+ (aliases && extractDependencies(expr).some((d) => aliases.has(d)));
391
+ if (!usesLocalScope) return whole;
392
+ let id = idByExpr.get(expr);
393
+ if (id === undefined) {
394
+ let value;
395
+ try {
396
+ value = evalInScope(expr, scopedState, el);
397
+ } catch {
398
+ return whole;
399
+ }
400
+ if (value === undefined) return whole;
401
+ id = `_p${__vibeIterPropCounter++}`;
402
+ registry[id] = value;
403
+ idByExpr.set(expr, id);
404
+ (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: null, expr });
405
+ (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
406
+ }
407
+ return `@[window.__vibeiterprops.${id}]`;
408
+ });
409
+ if (idByExpr.size) {
410
+ el._vibeSlotContent = rewritten;
411
+ el.setAttribute('data-vibe-iter-prop', '');
412
+ }
413
+ };
414
+
360
415
  // For <component src> elements inside an iteration instance, evaluate any
361
416
  // `@[expr]` attribute bindings against the iteration's scoped state and route
362
417
  // every resolved value through the global iteration-prop registry. The prop
@@ -422,6 +477,7 @@ export const resolveIterationComponentProps = (nodes, scopedState, aliases) => {
422
477
  // Leave binding raw — processComponent will handle it as a binding
423
478
  }
424
479
  }
480
+ resolveSlotContentBindings(el, scopedState, aliases);
425
481
  }
426
482
  }
427
483
  };
@@ -795,7 +851,57 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
795
851
  * that runs while clones are still detached.
796
852
  * @returns {Object} { element, tree, clonedNodes }
797
853
  */
798
- export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined) => {
854
+ // Compiled mode bakes a fixed `data-vibe-component-id` into each inlined
855
+ // component and stamps its `@[this.x]` bindings / `$.this.x` handlers to that
856
+ // id. An iteration clones its template once per row, so every row would
857
+ // otherwise share that one id — and thus one local-state bucket. (Two brawler
858
+ // slots both resolving to `_c2.open` is why opening one ability drawer opened
859
+ // them all.) Per row, remap every baked id in the clone to a fresh one and
860
+ // rewrite the references to it; the caller then runs the row's component
861
+ // scripts so each registers isolated state under its fresh id. Runtime mode has
862
+ // no baked ids here (components are still `<component src>`), so this no-ops.
863
+ const COMPONENT_ID = /^_c\d+$/;
864
+
865
+ const isolateInlinedComponentIds = (container) => {
866
+ const remap = new Map();
867
+ for (const el of container.querySelectorAll('[data-vibe-component-id]')) {
868
+ const oldId = el.getAttribute('data-vibe-component-id');
869
+ if (!COMPONENT_ID.test(oldId)) continue;
870
+ if (!remap.has(oldId)) remap.set(oldId, generateComponentId());
871
+ el.setAttribute('data-vibe-component-id', remap.get(oldId));
872
+ }
873
+ if (!remap.size) return false;
874
+
875
+ // `_c2` must not match inside `_c20` or a longer identifier, so anchor on a
876
+ // non-word/`$` boundary before and a non-digit/word after. Bindings always
877
+ // read the id as `_cN.prop`, so the trailing `.` satisfies the lookahead.
878
+ const refs = [...remap].map(([oldId, newId]) => [
879
+ new RegExp(`(?<![\\w$])${oldId}(?![\\w\\d])`, 'g'),
880
+ newId,
881
+ ]);
882
+ const rewrite = (str) => {
883
+ let out = str;
884
+ for (const [re, newId] of refs) out = out.replace(re, newId);
885
+ return out;
886
+ };
887
+ const walk = (node) => {
888
+ if (node.nodeType === 1) {
889
+ for (const attr of node.attributes) {
890
+ if (attr.value.includes('_c')) {
891
+ const next = rewrite(attr.value);
892
+ if (next !== attr.value) attr.value = next;
893
+ }
894
+ }
895
+ for (const child of node.childNodes) walk(child);
896
+ } else if (node.nodeType === 3 && node.textContent.includes('_c')) {
897
+ node.textContent = rewrite(node.textContent);
898
+ }
899
+ };
900
+ for (const node of container.childNodes) walk(node);
901
+ return true;
902
+ };
903
+
904
+ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, componentId = null, aliasSet = undefined, isolateComponents = false) => {
799
905
  let tree;
800
906
  let clonedNodes = [];
801
907
  let firstElement = null;
@@ -833,6 +939,13 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null, c
833
939
  if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
834
940
  }
835
941
 
942
+ // Give this row its own component ids (compiled mode) before parse() reads the
943
+ // bindings, then run the row's inlined setup scripts so each registers its own
944
+ // local state under the fresh id — mirroring the conditional-branch path.
945
+ if (isolateComponents && isolateInlinedComponentIds(parseContainer)) {
946
+ executeCompiledComponentScriptsIn([...parseContainer.childNodes]);
947
+ }
948
+
836
949
  if (useCachedTree) {
837
950
  // Fast path: map cached tree structure onto cloned DOM (no regex, no DOM walking)
838
951
  tree = cloneTreeWithElements(cachedTree, parseContainer);
@@ -1424,7 +1537,7 @@ const buildInstance = (iterationNode, item, index, state, parentScope, liveItem)
1424
1537
  const localVars = { [itemAlias]: liveItem, [indexAlias]: index };
1425
1538
  const scopedState = createScopedState(state, localVars, parentScope);
1426
1539
  const componentId = findComponentIdForElement(startComment.parentElement);
1427
- const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases));
1540
+ const built = initializeBlock([...template.element.childNodes], scopedState, template, componentId, new Set(iterationNode.meta.scopeAliases), true);
1428
1541
  const scopeAliases = new Set([itemAlias, indexAlias, ...(iterationNode.meta.scopeAliases || [])]);
1429
1542
  resolveIterationComponentProps(built.clonedNodes, scopedState, scopeAliases);
1430
1543
  return { ...built, scopedState, localVars, liveItem };