@ape-egg/vibe 1.9.1 → 1.9.5

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.
@@ -177,8 +177,15 @@ export const longestCommonSubsequence = (arr1, arr2) => {
177
177
  return lcs;
178
178
  };
179
179
 
180
- // Generate unique key for array items
181
- export const getItemKey = (item, index) => {
180
+ // Generate unique key for array items.
181
+ // `customKey` (when defined and not null) wins over every default heuristic —
182
+ // the developer has declared identity explicitly via `<!-- each xs as x (expr) -->`.
183
+ // The default heuristic only runs when no custom key was provided.
184
+ export const getItemKey = (item, index, customKey) => {
185
+ if (customKey !== undefined && customKey !== null) {
186
+ return `key_${customKey}`;
187
+ }
188
+
182
189
  // 1. If item has 'id' property, use it
183
190
  if (item && typeof item === 'object' && 'id' in item) {
184
191
  return `id_${item.id}`;
@@ -0,0 +1,157 @@
1
+ // Loop-scoped event handlers.
2
+ //
3
+ // An `on*` handler written inside a `<!-- each X as alias -->` loop can reference
4
+ // the bare loop variable directly, e.g. `onclick="pick(ability)"`. At parse time
5
+ // the alias token is rewritten to `$scope(this,'alias')`; at fire time that
6
+ // global resolver walks up the DOM to the nearest instance root stamped with the
7
+ // live item/index and returns it. This passes the *live object* (identity, not a
8
+ // stringified copy), works for derived-source loops, and survives reorders — all
9
+ // while keeping the handler a visible native `on*` attribute.
10
+ //
11
+ // See implement-loop-scoped-event-handlers.md for the full rationale.
12
+
13
+ const IDENT_START = /[A-Za-z_$]/;
14
+ const IDENT_PART = /[A-Za-z0-9_$]/;
15
+
16
+ // Last non-whitespace character already emitted — lets us tell a standalone
17
+ // identifier (rewrite) from a member access like `foo.alias` (leave alone).
18
+ const lastNonSpace = (s) => {
19
+ for (let i = s.length - 1; i >= 0; i--) {
20
+ const c = s[i];
21
+ if (c !== ' ' && c !== '\t' && c !== '\n' && c !== '\r') return c;
22
+ }
23
+ return '';
24
+ };
25
+
26
+ // Copy a quoted string literal beginning at `i` (value[i] is the opening quote)
27
+ // verbatim, honoring backslash escapes. Returns the index just past the closer.
28
+ const copyString = (value, i, push) => {
29
+ const quote = value[i];
30
+ push(quote);
31
+ i++;
32
+ const n = value.length;
33
+ while (i < n) {
34
+ const c = value[i];
35
+ if (c === '\\') {
36
+ push(c);
37
+ i++;
38
+ if (i < n) {
39
+ push(value[i]);
40
+ i++;
41
+ }
42
+ continue;
43
+ }
44
+ push(c);
45
+ i++;
46
+ if (c === quote) break;
47
+ }
48
+ return i;
49
+ };
50
+
51
+ // Rewrite standalone references to loop-variable aliases inside an event-handler
52
+ // expression into `$scope(this,'alias')` calls. Skips `@[...]` binding spans
53
+ // (they keep their existing hydrate-time stringifying behavior), string literals,
54
+ // and member accesses, so only identifiers that genuinely name a loop alias are
55
+ // touched.
56
+ export const rewriteHandlerAliases = (value, aliasSet) => {
57
+ if (!aliasSet || aliasSet.size === 0 || typeof value !== 'string') return value;
58
+
59
+ let out = '';
60
+ const push = (s) => {
61
+ out += s;
62
+ };
63
+ let i = 0;
64
+ const n = value.length;
65
+
66
+ while (i < n) {
67
+ const ch = value[i];
68
+
69
+ // @[...] binding span — copy verbatim. Track bracket depth and skip inner
70
+ // strings so a `]` inside a quoted expression doesn't close the span early.
71
+ if (ch === '@' && value[i + 1] === '[') {
72
+ push('@[');
73
+ i += 2;
74
+ let depth = 1;
75
+ while (i < n && depth > 0) {
76
+ const c = value[i];
77
+ if (c === "'" || c === '"') {
78
+ i = copyString(value, i, push);
79
+ continue;
80
+ }
81
+ if (c === '[') depth++;
82
+ else if (c === ']') depth--;
83
+ push(c);
84
+ i++;
85
+ }
86
+ continue;
87
+ }
88
+
89
+ // String literal — copy verbatim.
90
+ if (ch === "'" || ch === '"' || ch === '`') {
91
+ i = copyString(value, i, push);
92
+ continue;
93
+ }
94
+
95
+ // Identifier — rewrite when it's a standalone alias reference.
96
+ if (IDENT_START.test(ch)) {
97
+ let j = i + 1;
98
+ while (j < n && IDENT_PART.test(value[j])) j++;
99
+ const ident = value.slice(i, j);
100
+ const isMember = lastNonSpace(out) === '.';
101
+ if (!isMember && aliasSet.has(ident)) {
102
+ push(`$scope(this,'${ident}')`);
103
+ } else {
104
+ push(ident);
105
+ }
106
+ i = j;
107
+ continue;
108
+ }
109
+
110
+ push(ch);
111
+ i++;
112
+ }
113
+
114
+ return out;
115
+ };
116
+
117
+ // Walk up from `el` to the nearest ancestor stamped with a scope that defines
118
+ // `name`, returning the live value. Returns undefined if no enclosing loop
119
+ // defines the alias. Nested loops resolve naturally: the innermost stamp is hit
120
+ // first; an outer alias is found by continuing up past inner stamps.
121
+ export const resolveScope = (el, name) => {
122
+ let node = el;
123
+ while (node) {
124
+ const scope = node.__vibeScope;
125
+ if (scope && name in scope) return scope[name];
126
+ node = node.parentNode;
127
+ }
128
+ return undefined;
129
+ };
130
+
131
+ // Stamp the in-scope loop vars onto every iteration instance's root element
132
+ // node(s). The stamp accumulates the enclosing loop vars (`parentScope`) plus
133
+ // this loop's item/index, so a single innermost stamp resolves every alias in
134
+ // scope — which is what makes arbitrarily nested `each`/`if` combinations work
135
+ // even when a loop's template is purely another loop (no wrapper element to walk
136
+ // up to). Re-applied after each render and update so a stamp always reflects the
137
+ // current item/index — including after keyed reorders, where instance objects
138
+ // keep current `.item`/`.index`.
139
+ export const stampInstanceScopes = (iterationNode, parentScope = {}) => {
140
+ const { itemAlias, indexAlias } = iterationNode.meta;
141
+ const instances = iterationNode.runtime.instances;
142
+ for (let k = 0; k < instances.length; k++) {
143
+ const inst = instances[k];
144
+ const scope = { ...parentScope, [itemAlias]: inst.item, [indexAlias]: inst.index };
145
+ const roots = inst.clonedNodes || (inst.element ? [inst.element] : []);
146
+ for (let r = 0; r < roots.length; r++) {
147
+ const node = roots[r];
148
+ if (node && node.nodeType === 1) node.__vibeScope = scope;
149
+ }
150
+ }
151
+ };
152
+
153
+ // Install the global `$scope` resolver so native inline handlers (which run in
154
+ // global scope at fire time) can call it. Idempotent across boots.
155
+ export const installScopeResolver = () => {
156
+ globalThis.$scope = resolveScope;
157
+ };
package/runtime/parse.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  DEHYDRATE_CLASS_OR_ATTR,
9
9
  THIS_PROP_REGEX,
10
10
  } from './constants.js';
11
+ import { rewriteHandlerAliases } from './loop-scope.js';
11
12
 
12
13
  // Walks up the DOM for the nearest component wrapper tagged by component.js.
13
14
  // Used to rewrite `this.property` in event handlers to the component's state path.
@@ -23,7 +24,7 @@ const findComponentIdForElement = (element) => {
23
24
  // `<div class="component" src>`) returns nulls — its attributes are props
24
25
  // owned by processComponent and must stay raw; hydrating them would coerce
25
26
  // objects to "[object Object]" or strip boolean-like attrs to empty.
26
- const captureAttributeBindings = (element) => {
27
+ const captureAttributeBindings = (element, aliasSet) => {
27
28
  const nodeName = element.nodeName;
28
29
  const isFetchedComponent =
29
30
  (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
@@ -46,15 +47,25 @@ const captureAttributeBindings = (element) => {
46
47
  continue;
47
48
  }
48
49
 
49
- // Rewrite `this.property` inside event handlers to the component's state path.
50
- if (attr.name.startsWith('on') && attr.value.includes('this.')) {
51
- const componentId = findComponentIdForElement(element);
52
- if (componentId) {
53
- const rewritten = attr.value.replace(THIS_PROP_REGEX, (match, prop) => {
54
- return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
55
- });
56
- element.setAttribute(attr.name, rewritten);
50
+ // Event handlers get two compile-time rewrites, computed off the original
51
+ // value and written once:
52
+ // 1. `this.property` the component's state path (component-local state).
53
+ // 2. bare loop-variable aliases → `$scope(this,'alias')` (loop-scoped
54
+ // handlers only when an enclosing <!-- each --> alias is in scope).
55
+ if (attr.name.startsWith('on')) {
56
+ let v = attr.value;
57
+ if (v.includes('this.')) {
58
+ const componentId = findComponentIdForElement(element);
59
+ if (componentId) {
60
+ v = v.replace(THIS_PROP_REGEX, (match, prop) =>
61
+ DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`,
62
+ );
63
+ }
64
+ }
65
+ if (aliasSet && aliasSet.size > 0) {
66
+ v = rewriteHandlerAliases(v, aliasSet);
57
67
  }
68
+ if (v !== attr.value) element.setAttribute(attr.name, v);
58
69
  }
59
70
 
60
71
  BINDING_REGEX.lastIndex = 0;
@@ -69,6 +80,29 @@ const captureAttributeBindings = (element) => {
69
80
  };
70
81
  };
71
82
 
83
+ // Walk a parsed children map for any iteration node carrying scoped handlers.
84
+ // Each iteration node's own flag already aggregates its descendants, so we take
85
+ // the flag without re-descending into its template; we still recurse through
86
+ // elements and conditional branches to reach nested iteration nodes.
87
+ const subtreeHasScopedHandlers = (nodes) => {
88
+ for (const key in nodes) {
89
+ const node = nodes[key];
90
+ if (!node || typeof node !== 'object') continue;
91
+ if (node.type === 'iteration') {
92
+ if (node.meta?.hasScopedHandlers) return true;
93
+ continue;
94
+ }
95
+ if (node.type === 'conditional') {
96
+ const branches = node.meta?.branches;
97
+ if (branches?.if?.children && subtreeHasScopedHandlers(branches.if.children)) return true;
98
+ if (branches?.else?.children && subtreeHasScopedHandlers(branches.else.children)) return true;
99
+ continue;
100
+ }
101
+ if (node.children && subtreeHasScopedHandlers(node.children)) return true;
102
+ }
103
+ return false;
104
+ };
105
+
72
106
  const parseHTML = (children, rootKey = undefined) =>
73
107
  children.reduce((s, element, i) => {
74
108
  const { nodeName, textContent } = element;
@@ -81,7 +115,7 @@ const parseHTML = (children, rootKey = undefined) =>
81
115
  return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`;
82
116
  }, '');
83
117
 
84
- const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats = { skipped: 0 }) => {
118
+ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats = { skipped: 0 }, aliasSet = new Set()) => {
85
119
  let result = {};
86
120
 
87
121
  for (let i = 0; i < children.length; i++) {
@@ -103,7 +137,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
103
137
  const iterationMatch = textContent.trim().match(ITERATION_REGEX);
104
138
 
105
139
  if (iterationMatch) {
106
- const [_, arrayPath, itemAlias, indexAlias] = iterationMatch;
140
+ const [_, arrayPath, itemAlias, keyExpr, indexAlias] = iterationMatch;
107
141
 
108
142
  try {
109
143
  // Find matching end comment
@@ -118,8 +152,35 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
118
152
  templateContainer.appendChild(node.cloneNode(true));
119
153
  });
120
154
 
155
+ // Aliases in scope inside this loop's template = enclosing aliases plus
156
+ // this loop's item alias and (only when explicitly declared) its index
157
+ // alias. The implicit default index name is never auto-rewritten.
158
+ const childAliases = new Set(aliasSet);
159
+ childAliases.add(itemAlias);
160
+ if (indexAlias) childAliases.add(indexAlias);
161
+
121
162
  // Parse the template recursively
122
- const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats);
163
+ const templateParsed = recursive([...templateContainer.childNodes], undefined, new Set(), stats, childAliases);
164
+
165
+ // A handler rewritten to `$scope(this,'alias')` only exists in this
166
+ // freshly-parsed runtime template — the manifest's compiled batchFn was
167
+ // generated from the original (unrewritten) template, so it must be
168
+ // bypassed for this iteration (see canUseCompiled). True when a direct
169
+ // handler in this template was rewritten, OR a nested iteration carries
170
+ // scoped handlers (its inlined batchFn would be wrong too). The runtime
171
+ // clone / batch / diff paths all read the rewritten template correctly.
172
+ const hasScopedHandlers =
173
+ (childAliases.size > 0 && templateContainer.innerHTML.includes("$scope(this,")) ||
174
+ subtreeHasScopedHandlers(templateParsed);
175
+
176
+ // All aliases in scope inside this loop (enclosing + this loop's own) —
177
+ // passed back to parse() by iterate.js when it re-parses a cloned
178
+ // instance. Must be the ACCUMULATED set, not just this loop's own: a
179
+ // handler nested in a further if/each inside the loop can reference an
180
+ // outer alias that wasn't rewritten in this loop's own template (the
181
+ // inner structure's branch was extracted to a separate container), so
182
+ // the re-parse needs every enclosing alias to rewrite it.
183
+ const scopeAliases = [...childAliases];
123
184
 
124
185
  // Store iteration metadata (use index for deterministic keys)
125
186
  const iterationKey = `iteration_${i}`;
@@ -129,6 +190,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
129
190
  arrayPath,
130
191
  itemAlias,
131
192
  indexAlias: indexAlias || 'index',
193
+ keyExpr: keyExpr || null,
194
+ hasScopedHandlers,
195
+ scopeAliases,
132
196
  startComment: element,
133
197
  endComment: children[endIndex],
134
198
  template: {
@@ -183,8 +247,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
183
247
  trueBranchContainer.appendChild(node.cloneNode(true));
184
248
  });
185
249
 
186
- // Parse the true branch recursively
187
- const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats);
250
+ // Parse the true branch recursively (loop aliases stay in scope
251
+ // inside a conditional nested within an iteration).
252
+ const trueBranchParsed = recursive([...trueBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
188
253
 
189
254
  // Extract false branch nodes if else exists
190
255
  let falseBranchParsed = null;
@@ -195,7 +260,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
195
260
  falseBranchNodes.forEach((node) => {
196
261
  falseBranchContainer.appendChild(node.cloneNode(true));
197
262
  });
198
- falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
263
+ falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
199
264
  }
200
265
 
201
266
  // Store conditional metadata (use index for deterministic keys)
@@ -205,6 +270,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
205
270
  type: 'conditional',
206
271
  meta: {
207
272
  expression,
273
+ // Enclosing loop aliases — threaded back into parse() when
274
+ // mountBranch re-parses this branch, so loop-scoped handlers
275
+ // (including those nested deeper in further conditionals) rewrite.
276
+ scopeAliases: [...aliasSet],
208
277
  startComment: element,
209
278
  elseComment: elseIndex !== null ? children[elseIndex] : null,
210
279
  endComment: children[endIndex],
@@ -257,7 +326,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
257
326
  const elementForBindings = isTextNode ? element.parentElement : element;
258
327
  const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
259
328
 
260
- const { attributes, nameBindings } = captureAttributeBindings(element);
329
+ const { attributes, nameBindings } = captureAttributeBindings(element, aliasSet);
261
330
  const hasChildren = childNodes.length;
262
331
 
263
332
  if (hasChildren) {
@@ -267,7 +336,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
267
336
  result[rootKey || nodeIdentifier] = {
268
337
  parsed,
269
338
  element: elementForBindings,
270
- children: recursive(iteratableChildren, undefined, new Set(), stats),
339
+ children: recursive(iteratableChildren, undefined, new Set(), stats, aliasSet),
271
340
  ...(attributes && { attributes }),
272
341
  ...(nameBindings && { nameBindings }),
273
342
  ...(textNodeRef && { textNode: textNodeRef }),
@@ -287,17 +356,22 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
287
356
  return result;
288
357
  };
289
358
 
290
- export default (root, rootKey = undefined) => {
359
+ // `aliasSet` carries enclosing `<!-- each -->` aliases into the parse. The root
360
+ // page parse passes none; iterate.js passes a loop's own aliases when it
361
+ // re-parses a cloned instance subtree, so loop-scoped `on*` handlers (including
362
+ // those in nested loops, via child-alias accumulation in recursive) rewrite to
363
+ // `$scope(this,'alias')`.
364
+ export default (root, rootKey = undefined, aliasSet = new Set()) => {
291
365
  const { childNodes } = root;
292
366
  const stats = { skipped: 0 };
293
367
 
294
- const { attributes, nameBindings } = captureAttributeBindings(root);
368
+ const { attributes, nameBindings } = captureAttributeBindings(root, aliasSet);
295
369
 
296
370
  return {
297
371
  // html: root.outerHTML,
298
372
  parsed: parseHTML([...childNodes], rootKey),
299
373
  element: root,
300
- children: recursive(Array.from(childNodes), rootKey, new Set(), stats),
374
+ children: recursive(Array.from(childNodes), rootKey, new Set(), stats, aliasSet),
301
375
  ...(attributes && { attributes }),
302
376
  ...(nameBindings && { nameBindings }),
303
377
  stats,
@@ -8,6 +8,8 @@
8
8
  * Based on the prototype in _vibe-compiled-iteration-batch.js
9
9
  */
10
10
 
11
+ import { stampInstanceScopes } from './loop-scope.js';
12
+
11
13
  // Reusable template element for parsing compiled HTML
12
14
  const parseTemplate = typeof document !== 'undefined' ? document.createElement('template') : null;
13
15
 
@@ -33,6 +35,14 @@ export const canUseCompiled = (iterationNode) => {
33
35
  return false;
34
36
  }
35
37
 
38
+ // A template with a loop-scoped `$scope(this,'alias')` handler was rewritten at
39
+ // parse time; the manifest's compiled batchFn predates that rewrite and would
40
+ // emit the bare, unresolvable alias. Fall back to the runtime path, which reads
41
+ // the rewritten template.
42
+ if (iterationNode.meta.hasScopedHandlers) {
43
+ return false;
44
+ }
45
+
36
46
  // Check compiled data from manifest merge
37
47
  const compiled = iterationNode.compiled;
38
48
  if (!compiled || !compiled.iterations || !compiled.iterations.batchFn) {
@@ -116,6 +126,7 @@ export const renderCompiled = (iterationNode, array, state, compiledMeta, parent
116
126
 
117
127
  parent.insertBefore(frag, endComment);
118
128
  iterationNode.runtime.instances = instances;
129
+ stampInstanceScopes(iterationNode);
119
130
  return true;
120
131
  }
121
132
 
@@ -159,6 +170,7 @@ export const updateCompiled = (iterationNode, newArray, state, compiledMeta, sta
159
170
 
160
171
  parent.insertBefore(frag, endComment);
161
172
  iterationNode.runtime.instances = instances;
173
+ stampInstanceScopes(iterationNode);
162
174
  return true;
163
175
  }
164
176
 
package/runtime/state.js CHANGED
@@ -23,7 +23,7 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
23
23
  // For root level, rootState is the target itself
24
24
  if (rootState === null) {
25
25
  rootState = target;
26
- flushCallback = (props) => rerender(props);
26
+ flushCallback = typeof rerender === 'function' ? (props) => rerender(props) : null;
27
27
  }
28
28
 
29
29
  // Check cache first
@@ -50,6 +50,23 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
50
50
  return Reflect.set(obj, prop, value);
51
51
  },
52
52
 
53
+ deleteProperty(obj, prop) {
54
+ // Without notifying changedProps + scheduleFlush, `delete $.foo` is
55
+ // invisible to the reactive pipeline — bindings depending on `foo` (or
56
+ // on `Object.keys($)`) keep showing the deleted value. Critical for
57
+ // component state cleanup: releaseOrphanedComponentState calls
58
+ // `delete window.$[id]` and downstream consumers (e.g. a state
59
+ // inspector iterating root keys) need to re-render.
60
+ if (!(prop in obj)) return Reflect.deleteProperty(obj, prop);
61
+
62
+ const ref = Reflect.deleteProperty(obj, prop);
63
+ if (ref) {
64
+ changedProps.add(rootProp || prop);
65
+ scheduleFlush();
66
+ }
67
+ return ref;
68
+ },
69
+
53
70
  get(target, prop) {
54
71
  const value = Reflect.get(target, prop);
55
72
 
package/runtime/utils.js CHANGED
@@ -143,13 +143,48 @@ export const evalInScope = (expr, state, element = null) => {
143
143
  }
144
144
  };
145
145
 
146
+ // Walk a dotted path against a state-like object, falling back to
147
+ // case-insensitive key matching at each segment. Used by name-binding
148
+ // hydration (clone + batch) to recover camelCase property names that the
149
+ // HTML parser lowercased — `<icon @[fx.convertsIcon]>` arrives at the
150
+ // runtime as `@[fx.convertsicon]`, which doesn't match `convertsIcon` on
151
+ // `fx`. Bails on bracket/call expressions because those need a real
152
+ // evaluator (and `evalInScope` already handled them).
153
+ export const resolveCaseInsensitivePath = (state, path) => {
154
+ if (path.includes('[') || path.includes('(')) return undefined;
155
+ const segments = path.split('.');
156
+ let current = state;
157
+ for (const seg of segments) {
158
+ if (current == null) return undefined;
159
+ // Direct first — handles proxies (scoped iteration state) and plain objects.
160
+ if (Reflect.has(Object(current), seg)) {
161
+ current = current[seg];
162
+ continue;
163
+ }
164
+ if (typeof current !== 'object') return undefined;
165
+ const ci = Object.keys(current).find((k) => k.toLowerCase() === seg.toLowerCase());
166
+ if (!ci) return undefined;
167
+ current = current[ci];
168
+ }
169
+ return current;
170
+ };
171
+
146
172
  // Helper to find component ID for an element
147
173
  // Walks up DOM tree to find nearest component wrapper
148
174
  export const findComponentIdForElement = (element) => {
149
175
  if (!element || !element.closest) return null;
150
176
 
151
177
  const wrapper = element.closest('[data-vibe-component-id]');
152
- return wrapper ? wrapper.getAttribute('data-vibe-component-id') : null;
178
+ if (wrapper) return wrapper.getAttribute('data-vibe-component-id');
179
+ // Detached fallback: cloned-but-not-yet-attached subtrees (iteration row
180
+ // construction in `initializeBlock` parses + hydrates inside a fresh
181
+ // parseContainer before insertion). Walk back to the root and consult
182
+ // _vibeComponentId, which iteration code can stash on the parseContainer
183
+ // when it knows the row's owning component up front.
184
+ let root = element;
185
+ while (root.parentNode) root = root.parentNode;
186
+ if (root._vibeComponentId) return root._vibeComponentId;
187
+ return null;
153
188
  };
154
189
 
155
190
  // Helper to resolve this.property paths to componentId.property