@ape-egg/vibe 1.6.1 → 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.
@@ -84,6 +84,14 @@ struct Args {
84
84
  /// Skip iteration optimization - use runtime DOM cloning instead of compiled batch functions
85
85
  #[arg(long, name = "iterations-as-is")]
86
86
  iterations_as_is: bool,
87
+
88
+ /// Skip cleaning output directory before compilation
89
+ #[arg(long, name = "no-clean", hide = true)]
90
+ no_clean: bool,
91
+
92
+ /// Keep FOUC prevention class/attribute in compiled output
93
+ #[arg(long, name = "fouc-as-is")]
94
+ fouc_as_is: bool,
87
95
  }
88
96
 
89
97
  fn main() {
@@ -161,6 +169,12 @@ fn main() {
161
169
  overrides.iterations_as_is = !original_iterations_as_is;
162
170
  config.iterations_as_is = true;
163
171
  }
172
+ if args.no_clean {
173
+ config.no_clean = true;
174
+ }
175
+ if args.fouc_as_is {
176
+ config.fouc_as_is = true;
177
+ }
164
178
 
165
179
  if args.verbose {
166
180
  println!("{}", format!("Vibe Compiler v{}", env!("CARGO_PKG_VERSION")).cyan().bold());
@@ -191,21 +205,31 @@ fn main() {
191
205
  format!("{} (config: {})", value.to_string().green(), value.to_string().yellow())
192
206
  }
193
207
 
194
- // Alphabetically ordered with padding (longest key is "node-modules-as-is" = 18 chars)
195
- println!(" {}: {}", format!("{:<18}", "assets").cyan(), format_value_no_flag(&config._assets));
196
- println!(" {}: {}", format!("{:<18}", "components").cyan(), format_value_no_flag(&config.components));
197
- println!(" {}: {}", format!("{:<18}", "components-as-is").cyan(), format_bool_with_flag(config.components_as_is, overrides.components_as_is, original_components_as_is));
198
- println!(" {}: {}", format!("{:<18}", "elements-as-is").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
199
- println!(" {}: {}", format!("{:<18}", "exclude-tags").cyan(), format_value_no_flag(format!("{:?}", config.reserved_elements)));
200
- println!(" {}: {}", format!("{:<18}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
201
- println!(" {}: {}", format!("{:<18}", "node-modules-as-is").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
202
- println!(" {}: {}", format!("{:<18}", "output").cyan(), format_value_no_flag(&config._output_str));
203
- println!(" {}: {}", format!("{:<18}", "pages").cyan(), format_value_no_flag(&config.pages));
204
- println!(" {}: {}", format!("{:<18}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
205
- println!(" {}: {}", format!("{:<18}", "runtime-as-is").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
206
- println!(" {}: {}", format!("{:<18}", "source").cyan(), format_value_no_flag(&config._source_str));
207
- println!(" {}: {}", format!("{:<18}", "source-maps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
208
- println!(" {}: {}", format!("{:<18}", "validate").cyan(), format_bool_with_flag(config.validate, overrides.validate, original_validate));
208
+ fn format_reserved_elements(elements: &[String]) -> String {
209
+ if elements.len() <= 2 {
210
+ format!("{:?}", elements)
211
+ } else {
212
+ let remaining = elements.len() - 2;
213
+ format!("[\"component\", \"div\", ... + {} more]", remaining)
214
+ }
215
+ }
216
+
217
+ // Alphabetically ordered with padding (longest key is "reservedElements" = 16 chars)
218
+ println!(" {}: {}", format!("{:<16}", "assets").cyan(), format_value_no_flag(&config._assets));
219
+ println!(" {}: {}", format!("{:<16}", "components").cyan(), format_value_no_flag(&config.components));
220
+ println!(" {}: {}", format!("{:<16}", "componentsAsIs").cyan(), format_bool_with_flag(config.components_as_is, overrides.components_as_is, original_components_as_is));
221
+ println!(" {}: {}", format!("{:<16}", "elementsAsIs").cyan(), format_bool_with_flag(config.elements_as_is, overrides.elements_as_is, original_elements_as_is));
222
+ println!(" {}: {}", format!("{:<16}", "iterationsAsIs").cyan(), format_bool_with_flag(config.iterations_as_is, overrides.iterations_as_is, original_iterations_as_is));
223
+ println!(" {}: {}", format!("{:<16}", "minify").cyan(), format_bool_with_flag(config.minify, overrides.minify, original_minify));
224
+ println!(" {}: {}", format!("{:<16}", "nodeModulesAsIs").cyan(), format_bool_with_flag(config.node_modules_as_is, overrides.node_modules_as_is, original_node_modules_as_is));
225
+ println!(" {}: {}", format!("{:<16}", "output").cyan(), format_value_no_flag(&config._output_str));
226
+ println!(" {}: {}", format!("{:<16}", "pages").cyan(), format_value_no_flag(&config.pages));
227
+ println!(" {}: {}", format!("{:<16}", "reservedElements").cyan(), format_value_no_flag(format_reserved_elements(&config.reserved_elements)));
228
+ println!(" {}: {}", format!("{:<16}", "root").cyan(), format_value_no_flag(config._root.as_ref().map(|s| s.as_str()).unwrap_or("null")));
229
+ println!(" {}: {}", format!("{:<16}", "runtimeAsIs").cyan(), format_bool_with_flag(config.runtime_as_is, overrides.runtime_as_is, original_runtime_as_is));
230
+ println!(" {}: {}", format!("{:<16}", "source").cyan(), format_value_no_flag(&config._source_str));
231
+ println!(" {}: {}", format!("{:<16}", "sourceMaps").cyan(), format_bool_with_flag(config.source_maps, overrides.source_maps, original_source_maps));
232
+ println!(" {}: {}", format!("{:<16}", "validate").cyan(), format_bool_with_flag(config.validate, overrides.validate, original_validate));
209
233
  println!();
210
234
  } else {
211
235
  // Show version in non-verbose mode
@@ -71,6 +71,21 @@ impl HtmlParser {
71
71
  Ok(())
72
72
  }
73
73
 
74
+ /// Reload a single element from disk (when the file changes in watch mode)
75
+ pub fn reload_element(&mut self, path: &Path) -> Result<(), ParseError> {
76
+ if path.extension().map_or(false, |ext| ext == "html") {
77
+ if let Some(tag_name) = path.file_stem().and_then(|s| s.to_str()) {
78
+ let content = fs::read_to_string(path).map_err(|e| ParseError::ReadError {
79
+ path: path.display().to_string(),
80
+ source: e,
81
+ })?;
82
+ let element = Element::new(tag_name.to_string(), path.to_path_buf(), content);
83
+ self.cache.insert(tag_name.to_string(), element);
84
+ }
85
+ }
86
+ Ok(())
87
+ }
88
+
74
89
  /// Get an element by tag name
75
90
  pub fn _get_element(&self, tag_name: &str) -> Option<&Element> {
76
91
  self.cache.get(tag_name)
@@ -115,8 +130,20 @@ impl HtmlParser {
115
130
 
116
131
  // Step 2: Handle explicit <component src="..."> elements
117
132
  // If components_as_is is false, recursively inline all <component> elements
133
+ // Keep running until no more components are found (handles nested components in slots)
118
134
  if !components_as_is {
119
- result = self.inline_component_elements(&result, external_cache);
135
+ let mut iterations = 0;
136
+ let max_iterations = 50; // Prevent infinite loops
137
+ loop {
138
+ let before = result.clone();
139
+ result = self.inline_component_elements(&result, external_cache);
140
+ iterations += 1;
141
+
142
+ // Stop if no changes or max iterations reached
143
+ if result == before || iterations >= max_iterations {
144
+ break;
145
+ }
146
+ }
120
147
 
121
148
  // NOTE: Don't run inline_custom_elements again here - it causes infinite recursion
122
149
  // Custom elements inside components are already processed when the component was cached
@@ -366,7 +393,7 @@ impl HtmlParser {
366
393
  replacement = replacement.replace("<slot/>", slot_replacement);
367
394
  replacement = replacement.replace("<slot />", slot_replacement);
368
395
 
369
- // Keep the <component> wrapper
396
+ // Keep the <component> wrapper (without src attribute)
370
397
  let wrapper = format!("<component>{}</component>", replacement);
371
398
  result.replace_range(*start..*end, &wrapper);
372
399
  }
@@ -440,16 +467,41 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
440
467
  }
441
468
  }
442
469
 
470
+ // Sort by descending length so more specific tags (e.g. "accordion-content")
471
+ // are processed before shorter prefixes (e.g. "accordion"), preventing
472
+ // partial tag-name matches like <accordion([^>]*)> matching <accordion-content>
473
+ custom_tags.sort_by(|a, b| b.len().cmp(&a.len()));
474
+
475
+ // Pre-compile class attribute regex for merging existing class values
476
+ let class_attr_re = regex::Regex::new(r#"\bclass="([^"]*)""#).unwrap();
477
+
443
478
  // Transform each custom tag
444
- for tag in custom_tags {
445
- // Opening tag: <custom-tag attrs> -> <div class="custom-tag" attrs>
446
- let open_re = regex::Regex::new(&format!(r"<{}([^>]*)>", regex::escape(&tag))).unwrap();
447
- result = open_re
448
- .replace_all(&result, format!("<div class=\"{}\"$1>", tag).as_str())
449
- .to_string();
479
+ for tag in &custom_tags {
480
+ // Opening tag: require whitespace or end-of-tag after the tag name so that
481
+ // <accordion> does not accidentally match <accordion-content>
482
+ let open_re = regex::Regex::new(
483
+ &format!(r"<{}([\s][^>]*|)>", regex::escape(tag))
484
+ ).unwrap();
485
+
486
+ result = open_re.replace_all(&result, |caps: &regex::Captures| -> String {
487
+ let attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
488
+
489
+ // If the element already has class="...", merge tag name with existing value
490
+ if let Some(class_cap) = class_attr_re.captures(attrs) {
491
+ let existing = class_cap.get(1).unwrap().as_str();
492
+ let merged = format!("{} {}", tag, existing);
493
+ let new_attrs = class_attr_re.replace(
494
+ attrs,
495
+ format!(r#"class="{}""#, merged.trim()).as_str(),
496
+ );
497
+ format!("<div{}>", new_attrs)
498
+ } else {
499
+ format!("<div class=\"{}\"{}>", tag, attrs)
500
+ }
501
+ }).to_string();
450
502
 
451
503
  // Closing tag: </custom-tag> -> </div>
452
- let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(&tag))).unwrap();
504
+ let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag))).unwrap();
453
505
  result = close_re.replace_all(&result, "</div>").to_string();
454
506
  }
455
507
 
package/index.js CHANGED
@@ -1,15 +1,41 @@
1
1
  // Universal entry point for Vibe
2
2
  // Usage: import vibe from 'vibe/index.js'; vibe({ initialState }, { debug: true }, 'body');
3
3
 
4
- import { ensureBoot, boot, isBooted } from './boot.js';
4
+ import { boot, isBooted, ensureBoot } from './boot.js';
5
+
6
+ // Shared instance for queueing listeners before boot
7
+ let vibeInstance = null;
8
+
9
+ const createVibeInstance = () => ({
10
+ _pendingListeners: { afterUpdate: [], afterDomMutation: [], ready: [] },
11
+ on(event, callback) {
12
+ // If booted, delegate to window.$
13
+ if (isBooted() && window.$) {
14
+ return window.$.on(event, callback);
15
+ }
16
+
17
+ // Otherwise queue for later
18
+ if (this._pendingListeners[event]) {
19
+ this._pendingListeners[event].push(callback);
20
+ }
21
+ return () => {
22
+ this._pendingListeners[event] = this._pendingListeners[event].filter(cb => cb !== callback);
23
+ };
24
+ }
25
+ });
5
26
 
6
27
  const vibe = (state = {}, config, targetSelector) => {
7
28
  if (isBooted()) {
8
- // Already booted - mutate live state
29
+ // Already booted - merge state into live proxy
9
30
  Object.assign(window.$, state);
10
31
  return window.$;
11
32
  }
12
33
 
34
+ // Create shared instance on first call
35
+ if (!vibeInstance) {
36
+ vibeInstance = createVibeInstance();
37
+ }
38
+
13
39
  // Not booted yet - accumulate in global state registry
14
40
  if (!window.__vibeGlobalState) {
15
41
  window.__vibeGlobalState = {};
@@ -26,15 +52,18 @@ const vibe = (state = {}, config, targetSelector) => {
26
52
  window.__vibeTargetSelector = targetSelector;
27
53
  }
28
54
 
29
- // Explicit boot call (no state passed means "boot now")
55
+ // Explicit boot call (no state passed means "boot now with accumulated state")
30
56
  if (Object.keys(state).length === 0 && Object.keys(window.__vibeGlobalState).length > 0) {
31
57
  return boot();
32
58
  }
33
59
 
34
- // Ensure boot happens in microtask
60
+ // Queue boot in microtask to allow all component scripts to register
35
61
  ensureBoot();
36
62
 
37
- return null;
63
+ return vibeInstance;
38
64
  };
39
65
 
66
+ // Export function to get pending listeners (used by boot.js)
67
+ export const getPendingListeners = () => vibeInstance?._pendingListeners || null;
68
+
40
69
  export default vibe;
package/llms.txt CHANGED
@@ -129,7 +129,7 @@ Conditionals can be nested inside iterations and vice versa.
129
129
  Skip reactive processing for an element and its children:
130
130
 
131
131
  ```html
132
- <code dehydrate>@[this] displays literally, not parsed</code>
132
+ <code vibe-dehydrate>@[this] displays literally, not parsed</code>
133
133
  ```
134
134
 
135
135
  Use cases:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -68,13 +68,4 @@ export const cleanup = (rootElement, debug = false) => {
68
68
  debug,
69
69
  );
70
70
  }
71
-
72
- // Dispatch ready event to signal that Vibe has completed all initial processing
73
- if (typeof document !== 'undefined') {
74
- document.dispatchEvent(
75
- new CustomEvent('vibe:ready', {
76
- detail: { rootElement, cleanName, isClass },
77
- }),
78
- );
79
- }
80
71
  };
@@ -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
  },