@ape-egg/vibe 1.6.0 → 1.7.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.
@@ -36,7 +36,9 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
36
36
  const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
37
37
 
38
38
  if (componentElements.length === 0) {
39
- if (onComplete) onComplete();
39
+ // Defer onComplete to give user code a chance to register listeners
40
+ // This is important when all components are pre-compiled (no src attributes)
41
+ if (onComplete) queueMicrotask(() => onComplete());
40
42
  return;
41
43
  }
42
44
 
@@ -50,7 +52,8 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
50
52
  // Process next component
51
53
  processComponent(rootElement, onComplete, config);
52
54
  } else {
53
- if (onComplete) onComplete();
55
+ // Defer onComplete to give user code a chance to register listeners
56
+ if (onComplete) queueMicrotask(() => onComplete());
54
57
  }
55
58
  return;
56
59
  }
@@ -131,21 +134,21 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
131
134
  const thisRegex = /@\[this\.(\w+)\]/g;
132
135
 
133
136
  // Rewrite in text nodes
134
- Array.from(element.childNodes).forEach(node => {
137
+ Array.from(element.childNodes).forEach((node) => {
135
138
  if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
136
139
  node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
137
140
  }
138
141
  });
139
142
 
140
143
  // Rewrite in attributes
141
- Array.from(element.attributes || []).forEach(attr => {
144
+ Array.from(element.attributes || []).forEach((attr) => {
142
145
  if (attr.value.includes('@[this.')) {
143
146
  attr.value = attr.value.replace(thisRegex, `@[${componentId}.$1]`);
144
147
  }
145
148
  });
146
149
 
147
150
  // Recurse into children
148
- Array.from(element.children).forEach(child => {
151
+ Array.from(element.children).forEach((child) => {
149
152
  rewriteThisBindings(child);
150
153
  });
151
154
  };
@@ -195,9 +198,10 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
195
198
  // Check if element still has a parent (might have been removed during fetch)
196
199
  if (el.parentNode) {
197
200
  // Create clean wrapper element (preserve tag type: component or div.component)
198
- const newWrapper = el.tagName === 'DIV'
199
- ? document.createElement('div')
200
- : document.createElement('component');
201
+ const newWrapper =
202
+ el.tagName === 'DIV'
203
+ ? document.createElement('div')
204
+ : document.createElement('component');
201
205
 
202
206
  if (el.tagName === 'DIV') {
203
207
  newWrapper.className = 'component';
@@ -207,16 +211,8 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
207
211
  el.replaceWith(newWrapper);
208
212
  debugLog(PHASE_FETCH, src, debug);
209
213
 
210
- // Force immediate processing of the mutation (MutationObserver is async, but we need sync)
211
- // Use microtask to process right after replaceWith completes
212
- if (config._forceSync && config._processMutations && config._observer) {
213
- Promise.resolve().then(() => {
214
- const pending = config._observer.takeRecords();
215
- if (pending.length > 0) {
216
- config._processMutations(pending);
217
- }
218
- });
219
- }
214
+ // Let MutationObserver handle the mutation naturally
215
+ // It will call processMutations, which will call processComponent for the next component
220
216
  }
221
217
  })
222
218
  .catch((error) => {
package/runtime/index.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  PHASE_UPDATE,
19
19
  PHASE_MUTATE,
20
20
  PHASE_HYPERSPEED,
21
+ PHASE_READY,
21
22
  DEHYDRATE_CLASS_OR_ATTR,
22
23
  } from './constants.js';
23
24
  import { processComponent, abortComponentFetch } from './component.js';
@@ -70,12 +71,12 @@ const shouldProcessNode = (node) => {
70
71
  // Navigate tree using dot notation (handles .children at each level)
71
72
  const navigateTree = (tree, path) => {
72
73
  if (!path) return tree;
73
- return path.split('.').reduce((node, key) => node?.children?.[key], tree);
74
+ return path.split('.').filter(k => k).reduce((node, key) => node?.children?.[key], tree);
74
75
  };
75
76
 
76
77
  // Get or create a node in the tree at the given path
77
78
  const ensureNode = (tree, path) => {
78
- const keys = path.split('.');
79
+ const keys = path.split('.').filter(k => k);
79
80
  return keys.reduce((node, key) => {
80
81
  if (!node.children[key]) {
81
82
  node.children[key] = { children: {} };
@@ -497,6 +498,7 @@ const main = (s, config = {}, stringSelector = '') => {
497
498
  const hooks = {
498
499
  afterUpdate: [],
499
500
  afterDomMutation: [],
501
+ ready: [],
500
502
  };
501
503
 
502
504
  // Extract plain values from proxy (removes proxy wrappers)
@@ -950,6 +952,15 @@ const main = (s, config = {}, stringSelector = '') => {
950
952
  if (shouldCleanup(rootElement)) {
951
953
  cleanup(rootElement, debug);
952
954
  cleanupExecuted = true;
955
+
956
+ // Fire ready hook after cleanup completes
957
+ hooks.ready.forEach((callback) => {
958
+ try {
959
+ callback();
960
+ } catch (error) {
961
+ console.error('[vibe] Error in ready hook:', error);
962
+ }
963
+ });
953
964
  }
954
965
  };
955
966
 
@@ -16,9 +16,10 @@ import * as compiled from './pre-compiled-iterations.js';
16
16
  * Find a comment node with matching text content in the given nodes.
17
17
  */
18
18
  const findComment = (nodes, text) => {
19
+ const trimmedText = text.trim();
19
20
  for (let i = 0; i < nodes.length; i++) {
20
21
  const node = nodes[i];
21
- if (node.nodeType === 8 && node.textContent.trim() === text.trim()) {
22
+ if (node.nodeType === 8 && node.textContent.trim() === trimmedText) {
22
23
  return node;
23
24
  }
24
25
  }
@@ -36,9 +37,16 @@ const cloneTreeWithElements = (originalTree, clonedRoot) => {
36
37
  parsed: originalTree.parsed,
37
38
  element: clonedRoot,
38
39
  children: {},
39
- ...(originalTree.attributes && { attributes: originalTree.attributes }),
40
40
  };
41
41
 
42
+ // Avoid spread operator for performance
43
+ if (originalTree.attributes) {
44
+ cloned.attributes = originalTree.attributes;
45
+ }
46
+ if (originalTree.nameBindings) {
47
+ cloned.nameBindings = originalTree.nameBindings;
48
+ }
49
+
42
50
  if (!originalTree.children) return cloned;
43
51
 
44
52
  const clonedChildNodes = clonedRoot?.childNodes;
@@ -130,26 +138,42 @@ export const initializeBlock = (templateNodes, scopedState, cachedTree = null) =
130
138
  let clonedNodes = [];
131
139
  let firstElement = null;
132
140
 
133
- // Always use slow path: clone and parse from scratch
134
- // TODO: Re-enable fast path once cloneTreeWithElements properly handles nested conditionals
141
+ // TEMPORARY: Disable fast path to test if it's causing duplication
142
+ let canUseFastPath = false;
135
143
 
136
144
  // Create a container for parsing (to capture all nodes including comment nodes like <!-- if -->)
137
145
  const parseContainer = document.createElement('div');
138
146
 
139
- // Clone all template nodes into the container
140
- for (let i = 0; i < templateNodes.length; i++) {
141
- const cloned = templateNodes[i].cloneNode(true);
142
- parseContainer.appendChild(cloned);
143
- if (!firstElement && cloned.nodeType === 1) {
144
- firstElement = cloned;
147
+ if (canUseFastPath) {
148
+ // Fast path: clone template nodes and map to cached tree structure
149
+ for (let i = 0; i < templateNodes.length; i++) {
150
+ const cloned = templateNodes[i].cloneNode(true);
151
+ parseContainer.appendChild(cloned);
152
+ if (!firstElement && cloned.nodeType === 1) {
153
+ firstElement = cloned;
154
+ }
155
+ }
156
+ tree = cloneTreeWithElements(cachedTree, parseContainer);
157
+ } else {
158
+ // Slow path: clone and parse from scratch
159
+ for (let i = 0; i < templateNodes.length; i++) {
160
+ const cloned = templateNodes[i].cloneNode(true);
161
+ parseContainer.appendChild(cloned);
162
+ if (!firstElement && cloned.nodeType === 1) {
163
+ firstElement = cloned;
164
+ }
145
165
  }
166
+ // Parse the entire container (includes all nodes + conditionals)
167
+ tree = parse(parseContainer);
146
168
  }
147
169
 
148
- // Parse the entire container (includes all nodes + conditionals)
149
- tree = parse(parseContainer);
150
-
151
170
  // Extract the cloned nodes from the container (these are the same nodes the tree references)
152
- clonedNodes = Array.from(parseContainer.childNodes);
171
+ // Avoid Array.from for performance
172
+ const childNodes = parseContainer.childNodes;
173
+ clonedNodes = [];
174
+ for (let i = 0; i < childNodes.length; i++) {
175
+ clonedNodes.push(childNodes[i]);
176
+ }
153
177
 
154
178
  // If no firstElement found, use parseContainer as fallback
155
179
  if (!firstElement) {
@@ -271,6 +295,21 @@ export const renderIteration = (iterationNode, state, manifest, parentScope = {}
271
295
  return;
272
296
  }
273
297
 
298
+ // Fallback check: If markers are lost (e.g., comment nodes replaced by component loading),
299
+ // check actual DOM state between comments for hydrated nodes
300
+ let currentNode = startComment.nextSibling;
301
+ while (currentNode && currentNode !== endComment) {
302
+ if (currentNode.nodeType === 1) {
303
+ // Element node
304
+ const html = currentNode.outerHTML || '';
305
+ // If node doesn't have any @[...] syntax, it's been hydrated
306
+ if (!html.includes('@[')) {
307
+ return; // Already rendered
308
+ }
309
+ }
310
+ currentNode = currentNode.nextSibling;
311
+ }
312
+
274
313
  // Remove template nodes from DOM on first render
275
314
  if (!iterationNode.runtime.templateRemoved) {
276
315
  let node = startComment.nextSibling;
@@ -361,7 +400,16 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
361
400
  // Compiled path: Use pre-compiled batch function when available
362
401
  if (compiled.canUseCompiled(iterationNode)) {
363
402
  const compiledMeta = compiled.getCompiledMeta(iterationNode);
364
- if (compiled.updateCompiled(iterationNode, newArray, newState, compiledMeta, startComment, endComment)) {
403
+ if (
404
+ compiled.updateCompiled(
405
+ iterationNode,
406
+ newArray,
407
+ newState,
408
+ compiledMeta,
409
+ startComment,
410
+ endComment,
411
+ )
412
+ ) {
365
413
  return;
366
414
  }
367
415
  // Fall through to runtime path if compiled failed
package/runtime/parse.js CHANGED
@@ -12,7 +12,7 @@ const parseHTML = (children, rootKey = undefined) =>
12
12
  children.reduce((s, element, i) => {
13
13
  const { nodeName, textContent } = element;
14
14
  if (['#comment'].includes(nodeName)) {
15
- return `${s}${nodeName === '#comment' ? `asd` : textContent}`;
15
+ return s;
16
16
  }
17
17
  const name = nodeName.startsWith('#') ? nodeName.slice(1) : nodeName;
18
18
  const innerNodeIdentifier = `${name}_${i}`.toLowerCase();
@@ -77,8 +77,9 @@ const recursive = (children, rootKey = undefined, skipIndices = new Set(), stats
77
77
  },
78
78
  },
79
79
  // Preserve runtime data from previous parse if it exists (stored on startComment by iterate.js)
80
+ // Only restore if the comment node is still connected to the DOM (not replaced by component loading)
80
81
  // @ts-ignore - custom property added by iterate.js
81
- runtime: element.__vibeIterationRuntime || {
82
+ runtime: (element.isConnected && element.__vibeIterationRuntime) || {
82
83
  instances: [],
83
84
  templateRemoved: false,
84
85
  },
@@ -43,7 +43,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
43
43
  }
44
44
 
45
45
  // Check if parsed string contains bindings - split into array
46
- if (typeof node.parsed === 'string' && node.parsed.includes('@[')) {
46
+ if (typeof node.parsed === "string" && node.parsed.includes("@[")) {
47
47
  const parsedArray = splitByMarkers(node.parsed);
48
48
  if (parsedArray) {
49
49
  result.hyperspeedRestoration = {
@@ -53,10 +53,10 @@ export const buildHyperspeedManifest = (parsedTree) => {
53
53
  }
54
54
 
55
55
  // Check for attribute bindings
56
- if (node.attributes && typeof node.attributes === 'object') {
56
+ if (node.attributes && typeof node.attributes === "object") {
57
57
  const attrBindings = {};
58
58
  for (const [key, value] of Object.entries(node.attributes)) {
59
- if (typeof value === 'string' && value.includes('@[')) {
59
+ if (typeof value === "string" && value.includes("@[")) {
60
60
  attrBindings[key] = value;
61
61
  }
62
62
  }
@@ -69,7 +69,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
69
69
  }
70
70
 
71
71
  // For iterations, extract template as HTML string
72
- if (node.type === 'iteration' && node.meta?.template?.element) {
72
+ if (node.type === "iteration" && node.meta?.template?.element) {
73
73
  const templateElement = node.meta.template.element;
74
74
  if (templateElement && templateElement.innerHTML) {
75
75
  if (!result.hyperspeedRestoration) {
@@ -80,7 +80,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
80
80
  }
81
81
 
82
82
  // For conditionals, extract branch templates as HTML strings
83
- if (node.type === 'conditional' && node.meta?.branches) {
83
+ if (node.type === "conditional" && node.meta?.branches) {
84
84
  const trueBranch = node.meta.branches.true?.element;
85
85
  const falseBranch = node.meta.branches.false?.element;
86
86
 
@@ -98,7 +98,7 @@ export const buildHyperspeedManifest = (parsedTree) => {
98
98
  }
99
99
 
100
100
  // Recursively process children (skip runtime-only nodes)
101
- if (node.children && typeof node.children === 'object') {
101
+ if (node.children && typeof node.children === "object") {
102
102
  for (const [key, childNode] of Object.entries(node.children)) {
103
103
  result.children[key] = walkNode(childNode);
104
104
  }
@@ -116,8 +116,8 @@ export const buildHyperspeedManifest = (parsedTree) => {
116
116
  element: null,
117
117
  parsed: [],
118
118
  children: walkNode(parsedTree).children, // Use children directly
119
- }
120
- }
119
+ },
120
+ },
121
121
  };
122
122
  };
123
123
 
@@ -135,8 +135,22 @@ const detectHyperspeed = async () => {
135
135
  hyperspeedDetectionAttempted = true;
136
136
 
137
137
  try {
138
- const pagePath = window.location.pathname;
139
- const pathSegments = pagePath.split('/').filter(s => s);
138
+ let pagePath = window.location.pathname;
139
+
140
+ // Normalize path: handle directory URLs and missing extensions
141
+ if (pagePath.endsWith("/")) {
142
+ // /compiled/ -> /compiled/index.html
143
+ pagePath = pagePath + "index.html";
144
+ } else if (!pagePath.includes(".")) {
145
+ // /compiled/mypage -> /compiled/mypage.html
146
+ const lastSlash = pagePath.lastIndexOf("/");
147
+ const lastSegment = pagePath.substring(lastSlash + 1);
148
+ if (lastSegment && !lastSegment.includes(".")) {
149
+ pagePath = pagePath + ".html";
150
+ }
151
+ }
152
+
153
+ const pathSegments = pagePath.split("/").filter((s) => s);
140
154
 
141
155
  if (pathSegments.length === 0) return null;
142
156
 
@@ -151,9 +165,11 @@ const detectHyperspeed = async () => {
151
165
  // Strategy 1: vibe-hyperspeed at the same level as parent directory
152
166
  // /compiled/playground/test.html -> /compiled/vibe-hyperspeed/playground/test.html.manifest.js
153
167
  if (dirSegments.length >= 1) {
154
- const subPath = dirSegments.slice(1).join('/'); // Everything after first dir
155
- const baseDir = '/' + dirSegments[0]; // First directory segment
156
- possiblePaths.push(`${baseDir}/vibe-hyperspeed/${subPath ? subPath + '/' : ''}${fileName}.manifest.js`);
168
+ const subPath = dirSegments.slice(1).join("/"); // Everything after first dir
169
+ const baseDir = "/" + dirSegments[0]; // First directory segment
170
+ possiblePaths.push(
171
+ `${baseDir}/vibe-hyperspeed/${subPath ? subPath + "/" : ""}${fileName}.manifest.js`,
172
+ );
157
173
  }
158
174
 
159
175
  // Strategy 2: vibe-hyperspeed at web root (original behavior)
@@ -163,8 +179,10 @@ const detectHyperspeed = async () => {
163
179
  // Strategy 3: vibe-hyperspeed relative to immediate parent
164
180
  // /playground/test.html -> /vibe-hyperspeed/playground/test.html.manifest.js
165
181
  if (dirSegments.length > 0) {
166
- const relativePath = dirSegments.join('/');
167
- possiblePaths.push(`/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`);
182
+ const relativePath = dirSegments.join("/");
183
+ possiblePaths.push(
184
+ `/vibe-hyperspeed/${relativePath}/${fileName}.manifest.js`,
185
+ );
168
186
  }
169
187
 
170
188
  // Try each possible path
@@ -173,7 +191,7 @@ const detectHyperspeed = async () => {
173
191
  const module = await import(manifestPath);
174
192
  hyperspeedData = {
175
193
  manifest: module.default,
176
- path: manifestPath
194
+ path: manifestPath,
177
195
  };
178
196
  return hyperspeedData;
179
197
  } catch (e) {
@@ -203,7 +221,11 @@ const detectHyperspeed = async () => {
203
221
  * @param {Object} subtree - The matching subtree from manifest (e.g., manifest.children.body)
204
222
  * @param {Object} fullManifest - The full manifest (unused now, kept for compatibility)
205
223
  */
206
- export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest = null) => {
224
+ export const restoreMarkersFromManifest = (
225
+ rootElement,
226
+ subtree,
227
+ fullManifest = null,
228
+ ) => {
207
229
  const walkTree = (tree, element) => {
208
230
  if (!tree || !element) return;
209
231
 
@@ -213,17 +235,25 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
213
235
  if (restoration) {
214
236
  // Restore text content with markers if parsed contains bindings
215
237
  if (restoration.parsed && Array.isArray(restoration.parsed)) {
216
- const hasBindings = restoration.parsed.some(item =>
217
- typeof item === 'string' && item.includes('@[')
238
+ const hasBindings = restoration.parsed.some(
239
+ (item) => typeof item === "string" && item.includes("@["),
218
240
  );
219
241
 
220
242
  if (hasBindings) {
221
243
  // Reconstruct original content with markers
222
- const originalContent = restoration.parsed.join('');
244
+ let originalContent = restoration.parsed.join("");
245
+
246
+ // Transform component-scoped bindings back to this. format
247
+ // Compiler transforms @[this.count] → @[_c0.count] for stamping
248
+ // Runtime expects @[this.count], so transform back
249
+ originalContent = originalContent.replace(/@\[_c\d+\./g, "@[this.");
223
250
 
224
251
  // For text nodes, update parent's innerHTML
225
252
  // For elements with children, update only text nodes
226
- if (element.childNodes.length === 1 && element.childNodes[0].nodeType === 3) {
253
+ if (
254
+ element.childNodes.length === 1 &&
255
+ element.childNodes[0].nodeType === 3
256
+ ) {
227
257
  // Single text node - replace it
228
258
  element.childNodes[0].textContent = originalContent;
229
259
  } else if (element.childNodes.length === 0) {
@@ -239,7 +269,13 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
239
269
  // 1. Boolean-like attributes with falsy values were removed - restore them
240
270
  // 2. Value attributes were stamped - replace stamped values with markers
241
271
  if (restoration.attributes) {
242
- for (const [attrName, attrValue] of Object.entries(restoration.attributes)) {
272
+ for (let [attrName, attrValue] of Object.entries(
273
+ restoration.attributes,
274
+ )) {
275
+ // Transform component-scoped bindings back to this. format
276
+ // Compiler transforms @[this.count] → @[_c0.count], runtime expects @[this.count]
277
+ attrValue = attrValue.replace(/@\[_c\d+\./g, "@[this.");
278
+
243
279
  // Always set the attribute to restore the marker
244
280
  // - If missing (boolean-like, falsy): adds it back
245
281
  // - If present (value attr, stamped): replaces stamped value with marker
@@ -274,17 +310,22 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
274
310
  // Text nodes use childNodes indices which become invalid after DOM modifications
275
311
  if (tree.children) {
276
312
  for (const key in tree.children) {
277
- if (key.startsWith('text_')) {
313
+ if (key.startsWith("text_")) {
278
314
  const childTree = tree.children[key];
279
315
  const restoration = childTree.compiled?.restoration;
280
316
 
281
317
  if (restoration?.parsed && Array.isArray(restoration.parsed)) {
282
- const hasBindings = restoration.parsed.some(item =>
283
- typeof item === 'string' && item.includes('@[')
318
+ const hasBindings = restoration.parsed.some(
319
+ (item) => typeof item === "string" && item.includes("@["),
284
320
  );
285
321
 
286
322
  if (hasBindings) {
287
- const originalContent = restoration.parsed.join('');
323
+ // Transform component-scoped bindings back to this. format
324
+ let originalContent = restoration.parsed.join("");
325
+ originalContent = originalContent.replace(
326
+ /@\[_c\d+\./g,
327
+ "@[this.",
328
+ );
288
329
 
289
330
  // Extract index from key (e.g., text_0 -> 0)
290
331
  const match = key.match(/_(\d+)$/);
@@ -315,7 +356,7 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
315
356
  const childTree = tree.children[key];
316
357
 
317
358
  // Handle iteration restoration
318
- if (childTree.type === 'iteration' && childTree.compiled?.restoration) {
359
+ if (childTree.type === "iteration" && childTree.compiled?.restoration) {
319
360
  const restoration = childTree.compiled.restoration;
320
361
 
321
362
  // Find iteration comment by matching the expression
@@ -323,7 +364,10 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
323
364
  let endComment = null;
324
365
  let depth = 0;
325
366
 
326
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT);
367
+ const walker = document.createTreeWalker(
368
+ element,
369
+ NodeFilter.SHOW_COMMENT,
370
+ );
327
371
  while (walker.nextNode()) {
328
372
  const comment = walker.currentNode;
329
373
  const trimmed = comment.textContent.trim();
@@ -337,9 +381,9 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
337
381
  startComment = comment;
338
382
  depth = 1;
339
383
  } else if (startComment) {
340
- if (trimmed.startsWith('each ')) {
384
+ if (trimmed.startsWith("each ")) {
341
385
  depth++;
342
- } else if (trimmed === '/each') {
386
+ } else if (trimmed === "/each") {
343
387
  depth--;
344
388
  if (depth === 0) {
345
389
  endComment = comment;
@@ -349,6 +393,10 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
349
393
  }
350
394
  }
351
395
 
396
+ // Always delete the iteration node from tree - runtime will re-create it
397
+ // Do this even if comments weren't found (they might have been removed by parent restoration)
398
+ delete tree.children[key];
399
+
352
400
  if (startComment && endComment) {
353
401
  // Mark as processed
354
402
  startComment._vibeProcessed = true;
@@ -368,7 +416,7 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
368
416
  }
369
417
 
370
418
  // Insert the template (single item)
371
- const tempContainer = document.createElement('div');
419
+ const tempContainer = document.createElement("div");
372
420
  tempContainer.innerHTML = restoration.template;
373
421
 
374
422
  const fragment = document.createDocumentFragment();
@@ -378,30 +426,35 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
378
426
  parent.insertBefore(fragment, endComment);
379
427
  }
380
428
 
381
- // Delete the iteration node from tree - runtime will re-create it
382
- delete tree.children[key];
383
429
  continue;
384
430
  }
385
431
 
386
- if (childTree.type === 'conditional' && childTree.compiled?.restoration) {
432
+ if (
433
+ childTree.type === "conditional" &&
434
+ childTree.compiled?.restoration
435
+ ) {
387
436
  const restoration = childTree.compiled.restoration;
388
437
 
389
438
  // Find conditional comment markers in the current element
390
439
  // But SKIP conditionals that are inside iteration blocks
391
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_COMMENT);
440
+ const walker = document.createTreeWalker(
441
+ element,
442
+ NodeFilter.SHOW_COMMENT,
443
+ );
392
444
  let startComment = null;
393
445
  let endComment = null;
394
446
  let insideIteration = false;
447
+ let conditionalDepth = 0;
395
448
 
396
449
  while (walker.nextNode()) {
397
450
  const comment = walker.currentNode;
398
451
  const text = comment.textContent.trim();
399
452
 
400
453
  // Track if we're inside an iteration block
401
- if (text.startsWith('each ')) {
454
+ if (text.startsWith("each ")) {
402
455
  insideIteration = true;
403
456
  continue;
404
- } else if (text === '/each') {
457
+ } else if (text === "/each") {
405
458
  insideIteration = false;
406
459
  continue;
407
460
  }
@@ -412,14 +465,27 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
412
465
  // Skip already processed conditionals
413
466
  if (comment._vibeProcessed) continue;
414
467
 
415
- if (text.startsWith('if') && !startComment) {
416
- startComment = comment;
417
- } else if (text === '/if' && startComment && !endComment) {
418
- endComment = comment;
419
- break;
468
+ if (text.startsWith("if")) {
469
+ if (!startComment) {
470
+ startComment = comment;
471
+ conditionalDepth = 1;
472
+ } else {
473
+ // Track nested conditionals
474
+ conditionalDepth++;
475
+ }
476
+ } else if (text === "/if" && startComment) {
477
+ conditionalDepth--;
478
+ if (conditionalDepth === 0) {
479
+ endComment = comment;
480
+ break;
481
+ }
420
482
  }
421
483
  }
422
484
 
485
+ // Always delete the conditional node from tree - runtime will re-create it
486
+ // Do this even if comments weren't found (they might have been removed by parent restoration)
487
+ delete tree.children[key];
488
+
423
489
  if (startComment && endComment) {
424
490
  // Mark as processed
425
491
  startComment._vibeProcessed = true;
@@ -430,12 +496,12 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
430
496
  let current = startComment.nextSibling;
431
497
  while (current && current !== endComment) {
432
498
  const next = current.nextSibling;
433
- current.remove(); // Remove ALL nodes, including comment nodes
499
+ current.remove(); // Remove ALL nodes, including comment nodes
434
500
  current = next;
435
501
  }
436
502
 
437
503
  // Insert the template content BEFORE endComment
438
- const tempContainer = document.createElement('div');
504
+ const tempContainer = document.createElement("div");
439
505
  tempContainer.innerHTML = restoration.template;
440
506
 
441
507
  const fragment = document.createDocumentFragment();
@@ -444,10 +510,6 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
444
510
  }
445
511
  endComment.parentNode.insertBefore(fragment, endComment);
446
512
  }
447
-
448
- // Delete restoration data AND remove the conditional node from tree
449
- // Runtime will re-create it from the restored DOM
450
- delete tree.children[key];
451
513
  }
452
514
  }
453
515
  }
@@ -458,28 +520,33 @@ export const restoreMarkersFromManifest = (rootElement, subtree, fullManifest =
458
520
  const childTree = tree.children[key];
459
521
 
460
522
  // Skip conditionals and iterations (already processed/deleted above)
461
- if (childTree.type === 'conditional' || childTree.type === 'iteration') {
523
+ if (
524
+ childTree.type === "conditional" ||
525
+ childTree.type === "iteration"
526
+ ) {
462
527
  continue;
463
528
  }
464
529
 
465
530
  // Skip text nodes - already handled before conditional/iteration processing
466
- if (key.startsWith('text_')) {
531
+ if (key.startsWith("text_")) {
467
532
  continue;
468
533
  }
469
534
 
470
535
  // Find corresponding child element by tag name and index
471
536
  // Keys like "layout_1" mean the node at childNodes index 1 (includes text nodes)
472
- // NOT the second layout element
473
537
  // Tag names can contain digits (h1, h2, etc.) so use [a-z0-9-]+
474
538
  const match = key.match(/^([a-z0-9-]+)_(\d+)$/);
475
539
  if (match) {
476
540
  const tagName = match[1].toUpperCase();
477
541
  const nodeIndex = parseInt(match[2], 10);
478
542
 
479
- // Get the node at this childNodes index
480
543
  const childNode = element.childNodes[nodeIndex];
481
544
 
482
- if (childNode && childNode.nodeType === Node.ELEMENT_NODE && childNode.nodeName === tagName) {
545
+ if (
546
+ childNode &&
547
+ childNode.nodeType === Node.ELEMENT_NODE &&
548
+ childNode.nodeName === tagName
549
+ ) {
483
550
  walkTree(childTree, childNode);
484
551
  }
485
552
  }