@ape-egg/vibe 1.9.0 → 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
@@ -6,7 +6,9 @@ import {
6
6
  CONDITIONAL_REGEX,
7
7
  DOM_ELEMENT_PROPERTIES,
8
8
  DEHYDRATE_CLASS_OR_ATTR,
9
+ THIS_PROP_REGEX,
9
10
  } from './constants.js';
11
+ import { rewriteHandlerAliases } from './loop-scope.js';
10
12
 
11
13
  // Walks up the DOM for the nearest component wrapper tagged by component.js.
12
14
  // Used to rewrite `this.property` in event handlers to the component's state path.
@@ -22,7 +24,7 @@ const findComponentIdForElement = (element) => {
22
24
  // `<div class="component" src>`) returns nulls — its attributes are props
23
25
  // owned by processComponent and must stay raw; hydrating them would coerce
24
26
  // objects to "[object Object]" or strip boolean-like attrs to empty.
25
- const captureAttributeBindings = (element) => {
27
+ const captureAttributeBindings = (element, aliasSet) => {
26
28
  const nodeName = element.nodeName;
27
29
  const isFetchedComponent =
28
30
  (nodeName === 'COMPONENT' || (nodeName === 'DIV' && element.classList?.contains('component'))) &&
@@ -45,15 +47,25 @@ const captureAttributeBindings = (element) => {
45
47
  continue;
46
48
  }
47
49
 
48
- // Rewrite `this.property` inside event handlers to the component's state path.
49
- if (attr.name.startsWith('on') && attr.value.includes('this.')) {
50
- const componentId = findComponentIdForElement(element);
51
- if (componentId) {
52
- const rewritten = attr.value.replace(/\bthis\.(\w+)/g, (match, prop) => {
53
- return DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`;
54
- });
55
- 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);
56
67
  }
68
+ if (v !== attr.value) element.setAttribute(attr.name, v);
57
69
  }
58
70
 
59
71
  BINDING_REGEX.lastIndex = 0;
@@ -68,6 +80,29 @@ const captureAttributeBindings = (element) => {
68
80
  };
69
81
  };
70
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
+
71
106
  const parseHTML = (children, rootKey = undefined) =>
72
107
  children.reduce((s, element, i) => {
73
108
  const { nodeName, textContent } = element;
@@ -80,7 +115,7 @@ const parseHTML = (children, rootKey = undefined) =>
80
115
  return rootKey || `${s}${`\$[${innerNodeIdentifier}]`}`;
81
116
  }, '');
82
117
 
83
- 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()) => {
84
119
  let result = {};
85
120
 
86
121
  for (let i = 0; i < children.length; i++) {
@@ -102,7 +137,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
102
137
  const iterationMatch = textContent.trim().match(ITERATION_REGEX);
103
138
 
104
139
  if (iterationMatch) {
105
- const [_, arrayPath, itemAlias, indexAlias] = iterationMatch;
140
+ const [_, arrayPath, itemAlias, keyExpr, indexAlias] = iterationMatch;
106
141
 
107
142
  try {
108
143
  // Find matching end comment
@@ -117,8 +152,35 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
117
152
  templateContainer.appendChild(node.cloneNode(true));
118
153
  });
119
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
+
120
162
  // Parse the template recursively
121
- 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];
122
184
 
123
185
  // Store iteration metadata (use index for deterministic keys)
124
186
  const iterationKey = `iteration_${i}`;
@@ -128,6 +190,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
128
190
  arrayPath,
129
191
  itemAlias,
130
192
  indexAlias: indexAlias || 'index',
193
+ keyExpr: keyExpr || null,
194
+ hasScopedHandlers,
195
+ scopeAliases,
131
196
  startComment: element,
132
197
  endComment: children[endIndex],
133
198
  template: {
@@ -182,8 +247,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
182
247
  trueBranchContainer.appendChild(node.cloneNode(true));
183
248
  });
184
249
 
185
- // Parse the true branch recursively
186
- 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);
187
253
 
188
254
  // Extract false branch nodes if else exists
189
255
  let falseBranchParsed = null;
@@ -194,7 +260,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
194
260
  falseBranchNodes.forEach((node) => {
195
261
  falseBranchContainer.appendChild(node.cloneNode(true));
196
262
  });
197
- falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats);
263
+ falseBranchParsed = recursive([...falseBranchContainer.childNodes], undefined, new Set(), stats, aliasSet);
198
264
  }
199
265
 
200
266
  // Store conditional metadata (use index for deterministic keys)
@@ -204,6 +270,10 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
204
270
  type: 'conditional',
205
271
  meta: {
206
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],
207
277
  startComment: element,
208
278
  elseComment: elseIndex !== null ? children[elseIndex] : null,
209
279
  endComment: children[endIndex],
@@ -256,7 +326,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
256
326
  const elementForBindings = isTextNode ? element.parentElement : element;
257
327
  const textNodeRef = isTextNode ? element : null; // Store reference to actual text node
258
328
 
259
- const { attributes, nameBindings } = captureAttributeBindings(element);
329
+ const { attributes, nameBindings } = captureAttributeBindings(element, aliasSet);
260
330
  const hasChildren = childNodes.length;
261
331
 
262
332
  if (hasChildren) {
@@ -266,7 +336,7 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
266
336
  result[rootKey || nodeIdentifier] = {
267
337
  parsed,
268
338
  element: elementForBindings,
269
- children: recursive(iteratableChildren, undefined, new Set(), stats),
339
+ children: recursive(iteratableChildren, undefined, new Set(), stats, aliasSet),
270
340
  ...(attributes && { attributes }),
271
341
  ...(nameBindings && { nameBindings }),
272
342
  ...(textNodeRef && { textNode: textNodeRef }),
@@ -286,17 +356,22 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
286
356
  return result;
287
357
  };
288
358
 
289
- 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()) => {
290
365
  const { childNodes } = root;
291
366
  const stats = { skipped: 0 };
292
367
 
293
- const { attributes, nameBindings } = captureAttributeBindings(root);
368
+ const { attributes, nameBindings } = captureAttributeBindings(root, aliasSet);
294
369
 
295
370
  return {
296
371
  // html: root.outerHTML,
297
372
  parsed: parseHTML([...childNodes], rootKey),
298
373
  element: root,
299
- children: recursive(Array.from(childNodes), rootKey, new Set(), stats),
374
+ children: recursive(Array.from(childNodes), rootKey, new Set(), stats, aliasSet),
300
375
  ...(attributes && { attributes }),
301
376
  ...(nameBindings && { nameBindings }),
302
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
@@ -1,3 +1,5 @@
1
+ import { THIS_PROP_REGEX } from './constants.js';
2
+
1
3
  // Fast incrementing counter instead of expensive random hash
2
4
  let hashCounter = 0;
3
5
  export const hash = () => `_${hashCounter++}`;
@@ -43,7 +45,7 @@ export const evalInScope = (expr, state, element = null) => {
43
45
  const componentId = findComponentIdForElement(element);
44
46
  if (componentId) {
45
47
  // Replace this.property with $['componentId'].property
46
- normalized = normalized.replace(/\bthis\.(\w+)/g, `$['${componentId}'].$1`);
48
+ normalized = normalized.replace(THIS_PROP_REGEX, `$['${componentId}'].$1`);
47
49
  }
48
50
  }
49
51
 
@@ -141,13 +143,48 @@ export const evalInScope = (expr, state, element = null) => {
141
143
  }
142
144
  };
143
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
+
144
172
  // Helper to find component ID for an element
145
173
  // Walks up DOM tree to find nearest component wrapper
146
174
  export const findComponentIdForElement = (element) => {
147
175
  if (!element || !element.closest) return null;
148
176
 
149
177
  const wrapper = element.closest('[data-vibe-component-id]');
150
- 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;
151
188
  };
152
189
 
153
190
  // Helper to resolve this.property paths to componentId.property