@ape-egg/vibe 1.1.2 → 1.3.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.
package/runtime/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import state from './state.js';
2
2
  import parse from './parse.js';
3
3
  import createManifest from './manifest.js';
4
- import hydrate, { setPreviousState } from './hydrate.js';
4
+ import hydrate from './hydrate.js';
5
5
  import affected from './affected.js';
6
6
  import { deepMerge, hash } from './utils.js';
7
7
  import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
@@ -17,28 +17,50 @@ import {
17
17
  PHASE_OBSERVE,
18
18
  PHASE_UPDATE,
19
19
  PHASE_MUTATE,
20
- PHASE_COMPLETE,
20
+ PHASE_HYPERSPEED,
21
21
  } from './constants.js';
22
22
  import { processComponent, abortComponentFetch } from './component.js';
23
23
  import { debugLog } from './debug.js';
24
24
  import { shouldCleanup, cleanup } from './cleanup.js';
25
+ import { generateComponentId, executeComponentScript } from './component-state.js';
26
+ import {
27
+ buildHyperspeedManifest,
28
+ hyperspeedManifest,
29
+ restoreMarkersFromManifest,
30
+ } from './hyperspeed.js';
25
31
 
26
32
  // Wire up cross-module dependency after all modules are loaded
27
33
  setRenderAllConditionals(renderAllConditionals);
28
34
 
29
- // Check if node itself or any ancestor is non-reactive or dehydrated
30
- const isNonReactiveOrInside = (node) => {
35
+ // Check if node should be processed by Vibe
36
+ const shouldProcessNode = (node) => {
37
+ // Only process element nodes
38
+ if (node.nodeType !== 1) return false;
39
+
40
+ // Fast check first: skip nodes without Vibe syntax (cheapest check)
41
+ const html = node.outerHTML;
42
+ if (
43
+ !html.includes('@[') &&
44
+ !html.includes('<!-- each') &&
45
+ !html.includes('<!-- if') &&
46
+ !html.includes('<component')
47
+ ) {
48
+ return false;
49
+ }
50
+
51
+ // Node has Vibe syntax - now check if it's in a non-reactive context
31
52
  let current = node;
32
53
  while (current && current !== document.body) {
33
54
  if (NON_REACTIVE_ELEMENTS.includes(current.nodeName)) {
34
- return true;
55
+ return false;
35
56
  }
36
57
  if (current.hasAttribute?.('dehydrate')) {
37
- return true;
58
+ return false;
38
59
  }
39
60
  current = current.parentElement;
40
61
  }
41
- return false;
62
+
63
+ return true;
42
64
  };
43
65
 
44
66
  // Navigate tree using dot notation (handles .children at each level)
@@ -90,10 +112,11 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
90
112
 
91
113
  // 2. Hydrate (replace @[...] bindings)
92
114
  // Use empty object as "previous state" for new nodes so all bindings are affected
93
- const affectedElements = affected(parsedNode, isNewNode ? {} : previousState, state);
115
+ const oldStateForAffected = isNewNode ? {} : previousState;
116
+ const affectedElements = affected(parsedNode, oldStateForAffected, state);
94
117
  if (affectedElements.length > 0) {
95
118
  hydratedCount = affectedElements.length;
96
- hydrate(affectedElements, state, manifest);
119
+ hydrate(affectedElements, state, manifest, oldStateForAffected);
97
120
  }
98
121
 
99
122
  // 3. Conditionals (evaluate <!-- if -->)
@@ -114,34 +137,239 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
114
137
  return { hydratedCount, iteratedCount, evaluatedCount };
115
138
  };
116
139
 
140
+ /**
141
+ * Deep clone a tree node (to avoid mutating hyperspeed template)
142
+ */
143
+ const deepCloneNode = (node) => {
144
+ if (!node || typeof node !== 'object') return node;
145
+
146
+ const cloned = { ...node };
147
+
148
+ // Clone children recursively
149
+ if (node.children && typeof node.children === 'object') {
150
+ cloned.children = {};
151
+ for (const key in node.children) {
152
+ cloned.children[key] = deepCloneNode(node.children[key]);
153
+ }
154
+ }
155
+
156
+ // Clone meta if it exists
157
+ if (node.meta && typeof node.meta === 'object') {
158
+ cloned.meta = { ...node.meta };
159
+ if (node.meta.template) {
160
+ cloned.meta.template = { ...node.meta.template };
161
+ }
162
+ if (node.meta.branches) {
163
+ cloned.meta.branches = { ...node.meta.branches };
164
+ }
165
+ }
166
+
167
+ // Clone runtime if it exists
168
+ if (node.runtime && typeof node.runtime === 'object') {
169
+ cloned.runtime = { ...node.runtime };
170
+ if (Array.isArray(node.runtime.instances)) {
171
+ cloned.runtime.instances = [...node.runtime.instances];
172
+ }
173
+ }
174
+
175
+ return cloned;
176
+ };
177
+
178
+ /**
179
+ * Merge hyperspeed manifest with runtime manifest
180
+ * Hyperspeed provides structure/metadata, runtime populates DOM references
181
+ */
182
+ const mergeManifests = (hyperspeedTree, runtimeTree) => {
183
+ if (!hyperspeedTree) return runtimeTree;
184
+ if (!runtimeTree) return hyperspeedTree;
185
+
186
+ // Start with deep clone of hyperspeed (foundation)
187
+ const merged = deepCloneNode(hyperspeedTree);
188
+
189
+ // Helper to recursively augment hyperspeed with runtime data
190
+ const augmentWithRuntime = (mergedNode, runtimeNode) => {
191
+ if (!runtimeNode) return;
192
+
193
+ // Populate DOM references from runtime
194
+ if (runtimeNode.element) {
195
+ mergedNode.element = runtimeNode.element;
196
+ }
197
+
198
+ // Populate parsed data from runtime
199
+ if (runtimeNode.parsed) {
200
+ mergedNode.parsed = runtimeNode.parsed;
201
+ }
202
+
203
+ // For iterations: populate all meta and runtime from runtime
204
+ if (mergedNode.type === 'iteration' && runtimeNode.type === 'iteration') {
205
+ // Copy entire meta object from runtime (all properties needed)
206
+ mergedNode.meta = runtimeNode.meta;
207
+ // Copy runtime object (instances, etc.)
208
+ mergedNode.runtime = runtimeNode.runtime;
209
+ }
210
+
211
+ // For conditionals: populate all meta from runtime
212
+ if (mergedNode.type === 'conditional' && runtimeNode.type === 'conditional') {
213
+ // Copy entire meta object from runtime (all properties needed)
214
+ mergedNode.meta = runtimeNode.meta;
215
+ }
216
+
217
+ // Augment children recursively
218
+ if (runtimeNode.children) {
219
+ if (!mergedNode.children) mergedNode.children = {};
220
+
221
+ for (const key in runtimeNode.children) {
222
+ const runtimeChild = runtimeNode.children[key];
223
+
224
+ // Skip iterations - let runtime control them entirely
225
+ // Iterations are dynamic and restoration changes DOM structure
226
+ if (runtimeChild.type === 'iteration') {
227
+ mergedNode.children[key] = runtimeChild;
228
+ continue;
229
+ }
230
+
231
+ if (mergedNode.children[key]) {
232
+ // Child exists in both - augment it
233
+ augmentWithRuntime(mergedNode.children[key], runtimeNode.children[key]);
234
+ } else {
235
+ // Child only in runtime - add it (dynamic element discovered at runtime)
236
+ mergedNode.children[key] = runtimeNode.children[key];
237
+ }
238
+ }
239
+ }
240
+ };
241
+
242
+ // Augment hyperspeed foundation with runtime data
243
+ augmentWithRuntime(merged, runtimeTree);
244
+
245
+ // Preserve stats from runtime (hyperspeed won't have stats)
246
+ if (runtimeTree.stats) {
247
+ merged.stats = runtimeTree.stats;
248
+ }
249
+
250
+ return merged;
251
+ };
252
+
117
253
  // Store previous state for comparison (needs to be accessible by core loop)
118
254
  let previousState = {};
119
255
 
120
- const main = (s, attrName = 'vibe', config = {}) => {
256
+ const main = (s, config = {}, stringSelector = '') => {
121
257
  const debug = !!config?.debug;
122
258
  const verbose = !!config?.verbose;
123
259
 
260
+ // Detect if running in compiler's headless browser
261
+ // When true: skip cleanup to preserve [vibe] attribute in compiled HTML
262
+ const isCompiling = typeof window !== 'undefined' && window.__vibeCompiling === true;
263
+
124
264
  // Reset previous state for each new instance
125
265
  previousState = {};
126
266
 
127
- // Find element(s) with the specified attribute
128
- const elements = document.querySelectorAll(`[${attrName}]`);
267
+ // Use page-specific hyperspeed manifest (detected at module load)
268
+ const hyperspeedTree = hyperspeedManifest;
129
269
 
130
- if (elements.length === 0) {
131
- console.info(`[vibe] No element found with attribute "${attrName}". Falling back to body.`);
132
- } else if (elements.length > 1) {
133
- console.info(
134
- `[vibe] Multiple elements (${elements.length}) found with attribute "${attrName}". Hydrating the first one.`,
135
- );
270
+ let rootElement = document.body;
271
+
272
+ if (stringSelector) {
273
+ // Find element(s) with the specified attribute
274
+ const elements = document.querySelectorAll(`${stringSelector}`);
275
+ if (elements?.[0]) {
276
+ rootElement = elements[0];
277
+ }
136
278
  }
137
279
 
138
- const rootElement = elements[0] || document.body;
280
+ debugLog(PHASE_ATTACH, `Vibe attached to`, true, 0, rootElement);
281
+
282
+ // Component tagging is now handled by component.js before boot
283
+ // Component state is merged into s by boot.js
284
+
285
+ // RESTORATION PHASE: If hyperspeed detected, restore @[...] markers in DOM
286
+ // This allows pre-rendered values to be visible (no FOUC) but makes DOM reactive
287
+ let hyperspeedSubtree = null;
288
+ if (hyperspeedTree) {
289
+ debugLog(PHASE_HYPERSPEED, 'Applied pre-compiled vibe-hyperspeed/**/*.manifest.js', debug);
290
+
291
+ // Find matching subtree by DOM path (not just tag name)
292
+ // Build path from document to rootElement
293
+ const buildDomPath = (element) => {
294
+ const path = [];
295
+ let current = element;
296
+
297
+ // Walk up to document itself (include html in the path)
298
+ while (current && current.parentNode && current !== document) {
299
+ const parent = current.parentNode;
300
+
301
+ // Skip document node itself
302
+ if (parent === document) {
303
+ const siblings = Array.from(document.childNodes);
304
+ const index = siblings.indexOf(current);
305
+ const tag = current.nodeName.toLowerCase();
306
+ path.unshift({ tag, index });
307
+ break;
308
+ }
309
+
310
+ const siblings = Array.from(parent.childNodes);
311
+ const index = siblings.indexOf(current);
312
+ const tag = current.nodeName.toLowerCase();
313
+
314
+ path.unshift({ tag, index });
315
+ current = parent;
316
+ }
317
+
318
+ return path;
319
+ };
320
+
321
+ // Walk manifest tree following DOM path
322
+ const findManifestNodeByPath = (manifest, path) => {
323
+ let current = manifest;
324
+
325
+ for (const { tag, index } of path) {
326
+ if (!current || !current.children) return null;
327
+
328
+ // Look for matching child by tag_index pattern
329
+ const key = `${tag}_${index}`;
330
+ if (current.children[key]) {
331
+ current = current.children[key];
332
+ } else {
333
+ // Fallback: search all children for matching tag at this level
334
+ let found = false;
335
+ for (const childKey in current.children) {
336
+ if (childKey.startsWith(tag + '_')) {
337
+ current = current.children[childKey];
338
+ found = true;
339
+ break;
340
+ }
341
+ }
342
+ if (!found) return null;
343
+ }
344
+ }
345
+
346
+ return current;
347
+ };
139
348
 
140
- debugLog(PHASE_ATTACH, `Vibe attached to`, debug, 0, rootElement);
349
+ const domPath = buildDomPath(rootElement);
350
+ hyperspeedSubtree = findManifestNodeByPath(hyperspeedTree, domPath);
141
351
 
352
+ if (!hyperspeedSubtree) {
353
+ // Fallback to root if path matching fails
354
+ hyperspeedSubtree = hyperspeedTree;
355
+ }
356
+
357
+ restoreMarkersFromManifest(rootElement, hyperspeedSubtree, hyperspeedTree);
358
+ }
359
+
360
+ // Runtime parses DOM (which now has restored markers if hyperspeed was used)
142
361
  let parsedTree = parse(rootElement);
362
+
363
+ // Merge with hyperspeed if available (hyperspeed as foundation, runtime augments)
364
+ if (hyperspeedSubtree) {
365
+ parsedTree = mergeManifests(hyperspeedSubtree, parsedTree);
366
+ }
367
+
143
368
  let manifest = createManifest(parsedTree);
144
369
 
370
+ // Build hyperspeed manifest (before hydration, extract markers from parsed strings)
371
+ const hyperspeedManifestData = buildHyperspeedManifest(parsedTree);
372
+
145
373
  const manifestEntries = Object.entries(manifest);
146
374
  const totalCount = manifestEntries.length;
147
375
  const elementsOnly = manifestEntries.filter(
@@ -163,7 +391,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
163
391
  );
164
392
  }
165
393
  debugLog(PHASE_PARSE, segments, debug);
166
- debugLog(PHASE_MANIFEST, `DOM manifest created`, debug);
394
+ debugLog(
395
+ PHASE_MANIFEST,
396
+ hyperspeedTree ? 'DOM manifest merged with hyperspeed' : 'DOM manifest created',
397
+ debug,
398
+ );
167
399
 
168
400
  // Lifecycle hooks that users can subscribe to
169
401
  const hooks = {
@@ -171,19 +403,31 @@ const main = (s, attrName = 'vibe', config = {}) => {
171
403
  afterDomMutation: [],
172
404
  };
173
405
 
174
- // Observer reference and callback - defined here so state handler can access processMutations
175
- let observer = null;
176
- let processMutations = null;
406
+ // Extract plain values from proxy (removes proxy wrappers)
407
+ const extractPlainValue = (obj) => {
408
+ if (obj === null || typeof obj !== 'object') return obj;
409
+ if (Array.isArray(obj)) return obj.map(extractPlainValue);
410
+ const plain = {};
411
+ for (const key in obj) {
412
+ if (obj.hasOwnProperty(key)) {
413
+ plain[key] = extractPlainValue(obj[key]);
414
+ }
415
+ }
416
+ return plain;
417
+ };
418
+
419
+ // Observer callback will be defined below (already declared above before processComponent)
177
420
 
178
- const $ = state(s, (newState) => {
179
- const mergedState = deepMerge($, newState);
421
+ const $ = state(s, (newState, oldState) => {
422
+ // Extract current state (after mutation)
423
+ const currentState = extractPlainValue($);
424
+ const changedProp = Object.keys(newState)[0];
180
425
 
181
- // Find what changed
182
- const affectedElements = affected(parsedTree, previousState, mergedState);
426
+ // Find what changed (compare previousState vs currentState)
427
+ const affectedElements = affected(parsedTree, previousState, currentState);
183
428
 
184
429
  if (affectedElements.length > 0) {
185
- const changedKeys = Object.keys(newState).join(', ');
186
- debugLog(PHASE_UPDATE, `state changed (${changedKeys})`, debug);
430
+ debugLog(PHASE_UPDATE, `state changed (${changedProp})`, debug);
187
431
 
188
432
  // Capture pending mutations before disconnecting (takeRecords clears the queue)
189
433
  let pendingMutations = [];
@@ -194,11 +438,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
194
438
 
195
439
  // Run core loop on the full tree (hydrate will use affected list)
196
440
  // isNewNode = false because this is a state update, not a DOM mutation
197
- hydrate(affectedElements, mergedState, manifest);
441
+ hydrate(affectedElements, currentState, manifest, previousState);
198
442
 
199
443
  // After hydrate, check for conditional/iteration changes
200
- const conditionalCount = renderAllConditionals(parsedTree, mergedState, manifest);
201
- const iterationCount = renderAllIterations(parsedTree, mergedState, manifest);
444
+ const conditionalCount = renderAllConditionals(parsedTree, currentState, manifest);
445
+ const iterationCount = renderAllIterations(parsedTree, currentState, manifest);
202
446
 
203
447
  if (observer) {
204
448
  observer.observe(rootElement, {
@@ -215,22 +459,10 @@ const main = (s, attrName = 'vibe', config = {}) => {
215
459
  }
216
460
  }
217
461
 
218
- // Deep clone that handles proxies (structuredClone fails on proxy objects)
219
- const deepClone = (obj) => {
220
- if (obj === null || typeof obj !== 'object') return obj;
221
- if (Array.isArray(obj)) return obj.map(deepClone);
222
- const cloned = {};
223
- for (const key in obj) {
224
- if (obj.hasOwnProperty(key)) {
225
- cloned[key] = deepClone(obj[key]);
226
- }
227
- }
228
- return cloned;
229
- };
230
-
231
- const prev = deepClone(previousState);
232
- previousState = { ...$, ...newState };
233
- hooks.afterUpdate.forEach((callback) => callback(deepClone({ ...$ }), prev));
462
+ // Store previous state for hooks (currentState is already plain, no need to clone)
463
+ const prev = previousState;
464
+ previousState = currentState;
465
+ hooks.afterUpdate.forEach((callback) => callback(currentState, prev));
234
466
  });
235
467
 
236
468
  // Add hook subscription method (non-enumerable so it won't be spread/cloned with state)
@@ -244,8 +476,9 @@ const main = (s, attrName = 'vibe', config = {}) => {
244
476
  enumerable: false,
245
477
  });
246
478
 
247
- // Initial hydration
248
- const affectedElements = affected(parsedTree, $, $);
479
+ // Initial hydration - pass plain values so iteration can do reference comparison
480
+ const initialState = extractPlainValue($);
481
+ const affectedElements = affected(parsedTree, initialState, initialState);
249
482
 
250
483
  debugLog(
251
484
  PHASE_HYDRATE,
@@ -257,14 +490,13 @@ const main = (s, attrName = 'vibe', config = {}) => {
257
490
  debug,
258
491
  );
259
492
 
260
- hydrate(affectedElements, $, manifest);
261
-
493
+ // Hydrate with proxy $ so DOM bindings work
494
+ // Pass initialState as oldState for iterations (won't actually update, just initial render)
495
+ hydrate(affectedElements, $, manifest, initialState);
262
496
 
263
497
  // Render all iterations and conditionals after initial hydration
264
- setPreviousState($);
265
- previousState = { ...$ };
266
-
267
- const iterationCount = renderAllIterations(parsedTree, $, manifest);
498
+ // Use initialState (plain values) for iteration rendering so reference comparison works
499
+ const iterationCount = renderAllIterations(parsedTree, initialState, manifest);
268
500
  if (iterationCount > 0)
269
501
  debugLog(
270
502
  PHASE_ITERATE,
@@ -288,6 +520,13 @@ const main = (s, attrName = 'vibe', config = {}) => {
288
520
  debug,
289
521
  );
290
522
 
523
+ // After all initial rendering, capture a clean snapshot for comparison
524
+ previousState = extractPlainValue($);
525
+
526
+ // Observer reference and processMutations
527
+ let observer = null;
528
+ let processMutations = null;
529
+
291
530
  // Define observer callback as named function so we can call it manually for pending mutations
292
531
  processMutations = (mutations) => {
293
532
  // Early exit if no mutations to process (common case)
@@ -353,8 +592,8 @@ const main = (s, attrName = 'vibe', config = {}) => {
353
592
  });
354
593
 
355
594
  addedNodesList.forEach((node) => {
356
- // Skip if node itself or any ancestor is non-reactive
357
- if (isNonReactiveOrInside(node)) {
595
+ // Skip nodes that aren't element nodes, are non-reactive, or lack Vibe syntax
596
+ if (!shouldProcessNode(node)) {
358
597
  return;
359
598
  }
360
599
 
@@ -374,8 +613,13 @@ const main = (s, attrName = 'vibe', config = {}) => {
374
613
  picked.element = target;
375
614
  }
376
615
 
377
- // Parse the newly added node
378
- const name = `${node.nodeName.toLowerCase()}_${hash()}`;
616
+ // Parse the newly added node (use monotonically increasing counter for deterministic key)
617
+ // Initialize counter if it doesn't exist
618
+ if (!picked._nextChildIndex) {
619
+ picked._nextChildIndex = Object.keys(picked.children).length;
620
+ }
621
+ const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
622
+ picked._nextChildIndex++; // Always increment, never decrement
379
623
  const parsedNode = parse(node);
380
624
 
381
625
  // Accumulate skipped stats
@@ -539,12 +783,16 @@ const main = (s, attrName = 'vibe', config = {}) => {
539
783
  ...config,
540
784
  _forceSync: true,
541
785
  _observer: observer,
542
- _processMutations: processMutations
786
+ _processMutations: processMutations,
543
787
  };
544
- processComponent(rootElement, () => {
545
- // When all components are done, run cleanup check
546
- checkCleanup();
547
- }, componentConfig);
788
+ processComponent(
789
+ rootElement,
790
+ () => {
791
+ // When all components are done, run cleanup check
792
+ checkCleanup();
793
+ },
794
+ componentConfig,
795
+ );
548
796
  }
549
797
  };
550
798
 
@@ -567,7 +815,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
567
815
 
568
816
  // Check if cleanup should run
569
817
  const checkCleanup = () => {
570
- if (cleanupExecuted) return;
818
+ if (cleanupExecuted || isCompiling) return;
571
819
 
572
820
  // Check for pending mutations first
573
821
  const pendingMutations = observer ? observer.takeRecords() : [];
@@ -580,7 +828,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
580
828
 
581
829
  // Check if all processing is complete
582
830
  if (shouldCleanup(rootElement)) {
583
- cleanup(rootElement, attrName, debug);
831
+ cleanup(rootElement, debug);
584
832
  cleanupExecuted = true;
585
833
  }
586
834
  };
@@ -596,7 +844,7 @@ const main = (s, attrName = 'vibe', config = {}) => {
596
844
  ...config,
597
845
  _forceSync: true,
598
846
  _observer: observer,
599
- _processMutations: processMutations
847
+ _processMutations: processMutations,
600
848
  };
601
849
 
602
850
  processComponent(
@@ -608,6 +856,11 @@ const main = (s, attrName = 'vibe', config = {}) => {
608
856
  componentConfig,
609
857
  );
610
858
 
859
+ // Export hyperspeed manifest globally for compiler extraction
860
+ if (typeof window !== 'undefined') {
861
+ window.__vibeManifest = hyperspeedManifestData;
862
+ }
863
+
611
864
  return $;
612
865
  };
613
866
 
@@ -2,6 +2,7 @@ import parse from './parse.js';
2
2
  import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { resolvePath, getItemKey, computeDiff } from './iteration-utils.js';
5
+ import { resolveThisPath } from './utils.js';
5
6
 
6
7
  // Fast path for iteration rendering (opt-in via window.__VIBE_FAST_ITERATION__)
7
8
  // See: _vibe-compiled-iteration-batch.js for implementation details
@@ -153,7 +154,7 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
153
154
  }
154
155
 
155
156
  if (tree) {
156
- const affectedElements = affected(tree, {}, scopedState);
157
+ const affectedElements = affected(tree, {}, scopedState, [], scopedState);
157
158
  hydrate(affectedElements, scopedState);
158
159
  }
159
160
 
@@ -278,7 +279,10 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
278
279
 
279
280
  const parent = startComment.parentNode;
280
281
 
281
- const array = resolvePath(state, arrayPath);
282
+ // Handle this.property for component-scoped arrays
283
+ const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
284
+
285
+ const array = resolvePath(state, resolvedArrayPath);
282
286
  if (!Array.isArray(array) || array.length === 0) {
283
287
  iterationNode.runtime.instances = [];
284
288
  return;
@@ -318,8 +322,11 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
318
322
  iterationNode.runtime.instances = instances;
319
323
 
320
324
  // Mark comment as rendered (survives re-parsing since comment nodes persist in DOM)
325
+ // Also store runtime data on the DOM node so it persists across re-parses
321
326
  // @ts-ignore - adding custom property to comment node
322
327
  startComment.__vibeRendered = true;
328
+ // @ts-ignore - adding custom property to comment node
329
+ startComment.__vibeIterationRuntime = iterationNode.runtime;
323
330
  };
324
331
 
325
332
  // Update an iteration block when array changes
@@ -327,8 +334,12 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
327
334
  if (!iterationNode.runtime.instances || !iterationNode.runtime.templateRemoved) return;
328
335
 
329
336
  const { arrayPath, template, startComment, endComment } = iterationNode.meta;
330
- const oldArray = resolvePath(oldState, arrayPath) || [];
331
- const newArray = resolvePath(newState, arrayPath) || [];
337
+
338
+ // Handle this.property for component-scoped arrays
339
+ const resolvedArrayPath = resolveThisPath(arrayPath, startComment.parentElement);
340
+
341
+ const oldArray = resolvePath(oldState, resolvedArrayPath) || [];
342
+ const newArray = resolvePath(newState, resolvedArrayPath) || [];
332
343
 
333
344
  // Fast path: opt-in via window.__VIBE_FAST_ITERATION__ (preview of Vibe Compiled)
334
345
  // Use for bulk operations (large arrays or empty→full transitions)