@ape-egg/vibe 1.6.1 → 1.7.1

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.
@@ -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
@@ -228,6 +255,65 @@ impl HtmlParser {
228
255
  /// Recursively inline all <component> elements with their HTML content
229
256
  /// Inline a single component (one pass, no looping)
230
257
  /// Used during recursive component fetching to resolve nested components
258
+ /// Find the start position of the matching close tag, handling nesting of the same tag.
259
+ /// `after_open`: byte position immediately after the `>` of the opening tag.
260
+ fn find_matching_close(content: &str, after_open: usize, tag_name: &str) -> Option<usize> {
261
+ // \b ensures <component> matches but not <component-foo>
262
+ let open_re = regex::Regex::new(&format!(r"<{}\b", regex::escape(tag_name))).unwrap();
263
+ let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag_name))).unwrap();
264
+
265
+ let mut depth = 1i32;
266
+ let mut cursor = after_open;
267
+
268
+ while cursor < content.len() {
269
+ let slice = &content[cursor..];
270
+ let next_open = open_re.find(slice).map(|m| (m.start(), m.end()));
271
+ let next_close = close_re.find(slice).map(|m| (m.start(), m.end()));
272
+
273
+ match (next_open, next_close) {
274
+ (None, None) => return None,
275
+ (None, Some((cs, ce))) => {
276
+ depth -= 1;
277
+ if depth == 0 { return Some(cursor + cs); }
278
+ cursor += ce;
279
+ }
280
+ (Some((os, oe)), None) => {
281
+ // Check if self-closing by finding the end of this tag
282
+ if let Some(end) = slice[os..].find('>') {
283
+ if slice[os..os + end + 1].ends_with("/>") {
284
+ cursor += os + end + 1;
285
+ } else {
286
+ depth += 1;
287
+ cursor += oe;
288
+ }
289
+ } else {
290
+ return None;
291
+ }
292
+ }
293
+ (Some((os, oe)), Some((cs, ce))) => {
294
+ if os < cs {
295
+ if let Some(end) = slice[os..].find('>') {
296
+ if slice[os..os + end + 1].ends_with("/>") {
297
+ cursor += os + end + 1;
298
+ } else {
299
+ depth += 1;
300
+ cursor += oe;
301
+ }
302
+ } else {
303
+ return None;
304
+ }
305
+ } else {
306
+ depth -= 1;
307
+ if depth == 0 { return Some(cursor + cs); }
308
+ cursor += ce;
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ None
315
+ }
316
+
231
317
  pub fn inline_single_component(&self, content: &str, component_src: &str, component_content: &str) -> String {
232
318
  let mut result = content.to_string();
233
319
 
@@ -243,55 +329,53 @@ impl HtmlParser {
243
329
  }
244
330
  };
245
331
 
246
- // Match <component src="..." attrs...>children</component>
247
- // OR <div class="component" src="..." attrs...>children</div>
248
- let pattern = format!(r#"(?s)<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>(.*?)</(component|div)>"#, regex::escape(&normalized_src));
249
- let component_re = regex::Regex::new(&pattern).unwrap();
332
+ // Match only the opening tag — slot content is extracted via depth-counting close search
333
+ // to correctly handle slot content that contains </div> or nested <component> elements.
334
+ let pattern = format!(r#"<(component|div)\s+([^>]*)\bsrc="{}"\s*([^>]*)>"#, regex::escape(&normalized_src));
335
+ let open_re = regex::Regex::new(&pattern).unwrap();
250
336
 
251
- let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
252
- let full_match = cap.get(0).unwrap();
337
+ let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
338
+ let open_tag = cap.get(0).unwrap();
253
339
  let tag_name = cap.get(1).unwrap().as_str();
254
340
  let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
255
341
  let attrs_after = cap.get(3).map(|m| m.as_str()).unwrap_or("");
256
- let slot_content = cap.get(4).map(|m| m.as_str().to_string());
257
342
 
258
343
  // For <div>, verify it has class="component"
259
344
  if tag_name == "div" {
260
345
  let combined_attrs = format!("{} {}", attrs_before, attrs_after);
261
346
  if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
262
- return None; // Not a component div, skip
347
+ return None;
263
348
  }
264
349
  }
265
350
 
266
- // Combine all attributes
351
+ let open_end = open_tag.end();
352
+ let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
353
+ let close_end = close_start + format!("</{}>", tag_name).len();
354
+ let slot_content = result[open_end..close_start].to_string();
355
+
267
356
  let attrs_str = format!("{} {}", attrs_before, attrs_after);
268
357
  let props = Self::parse_props(&attrs_str);
269
358
 
270
- Some((full_match.start(), full_match.end(), props, slot_content))
271
- }).flatten().collect();
359
+ Some((open_tag.start(), close_end, props, slot_content))
360
+ }).collect();
361
+
362
+ // Replace from end to start (sorted descending by start position)
363
+ let mut sorted = matches;
364
+ sorted.sort_by(|a, b| b.0.cmp(&a.0));
272
365
 
273
- // Replace from end to start
274
- for (start, end, props, slot_content) in matches.iter().rev() {
366
+ for (start, end, props, slot_content) in &sorted {
275
367
  let mut replacement = component_content.to_string();
276
368
 
277
- // Replace props
278
369
  for (prop_name, prop_value) in props {
279
370
  let prop_binding = format!("@[{}]", prop_name);
280
371
  replacement = replacement.replace(&prop_binding, prop_value);
281
372
  }
282
373
 
283
- // Replace slots
284
- let slot_replacement = slot_content
285
- .as_ref()
286
- .filter(|s| !s.trim().is_empty())
287
- .map(|s| s.as_str())
288
- .unwrap_or("");
289
-
374
+ let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
290
375
  replacement = replacement.replace("<slot></slot>", slot_replacement);
291
376
  replacement = replacement.replace("<slot/>", slot_replacement);
292
377
  replacement = replacement.replace("<slot />", slot_replacement);
293
378
 
294
- // Keep the <component> wrapper
295
379
  let wrapper = format!("<component>{}</component>", replacement);
296
380
  result.replace_range(*start..*end, &wrapper);
297
381
  }
@@ -302,73 +386,81 @@ impl HtmlParser {
302
386
  fn inline_component_elements(&self, content: &str, external_cache: &HashMap<String, String>) -> String {
303
387
  let mut result = content.to_string();
304
388
 
305
- // Single pass - all components in cache are already fully resolved
306
- // Match <component src="..." attrs...>children</component>
307
- // OR <div class="component" src="..." attrs...>children</div> (after accessibility transformation)
308
- // Both signatures require src attribute (wrappers without src are already processed)
309
- let component_re = regex::Regex::new(
310
- r#"(?s)<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>(.*?)</(component|div)>"#
389
+ // Match only the opening component tag slot content is extracted via depth-counting
390
+ // to correctly handle slot content containing </div> or nested <component> elements.
391
+ let open_re = regex::Regex::new(
392
+ r#"<(component|div)\s+([^>]*)\bsrc="([^"]+)"([^>]*)>"#
311
393
  ).unwrap();
312
394
 
313
- let matches: Vec<_> = component_re.captures_iter(&result).map(|cap| {
314
- let full_match = cap.get(0).unwrap();
315
- let tag_name = cap.get(1).unwrap().as_str();
316
- let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
317
- let src = cap.get(3).unwrap().as_str();
318
- let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
319
- let slot_content = cap.get(5).map(|m| m.as_str().to_string());
320
-
321
- // For <div>, verify it has class="component" (or class="component other-classes")
322
- if tag_name == "div" {
323
- let combined_attrs = format!("{} {}", attrs_before, attrs_after);
324
- if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
325
- return None; // Not a component div, skip
395
+ // Process one match at a time with re-scanning after each replacement.
396
+ // This is required for nested components (e.g. Layout.html wrapping inner components):
397
+ // collecting all byte positions at once and applying them in reverse fails because
398
+ // replacing an inner component changes the string length, invalidating the outer
399
+ // component's end byte position and causing a char boundary panic.
400
+ loop {
401
+ let matches: Vec<_> = open_re.captures_iter(&result).filter_map(|cap| {
402
+ let open_tag = cap.get(0).unwrap();
403
+ let tag_name = cap.get(1).unwrap().as_str();
404
+ let attrs_before = cap.get(2).map(|m| m.as_str()).unwrap_or("");
405
+ let src = cap.get(3).unwrap().as_str();
406
+ let attrs_after = cap.get(4).map(|m| m.as_str()).unwrap_or("");
407
+
408
+ // For <div>, verify it has class="component"
409
+ if tag_name == "div" {
410
+ let combined_attrs = format!("{} {}", attrs_before, attrs_after);
411
+ if !combined_attrs.contains("class=") || !combined_attrs.contains("component") {
412
+ return None;
413
+ }
326
414
  }
327
- }
328
415
 
329
- // Combine all attributes (excluding src which we already extracted)
330
- let attrs_str = format!("{} {}", attrs_before, attrs_after);
331
- let props = Self::parse_props(&attrs_str);
416
+ let open_end = open_tag.end();
417
+ let close_start = Self::find_matching_close(&result, open_end, tag_name)?;
418
+ let close_end = close_start + format!("</{}>", tag_name).len();
419
+ let slot_content = result[open_end..close_start].to_string();
332
420
 
333
- Some((full_match.start(), full_match.end(), src.to_string(), props, slot_content))
334
- }).flatten().collect();
421
+ let attrs_str = format!("{} {}", attrs_before, attrs_after);
422
+ let props = Self::parse_props(&attrs_str);
335
423
 
336
- // Replace from end to start
337
- for (start, end, src, props, slot_content) in matches.iter().rev() {
338
- // Normalize path
339
- let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
340
- src.to_string()
341
- } else {
342
- let without_prefix = src.trim_start_matches("./");
343
- if without_prefix.starts_with('/') {
344
- without_prefix.to_string()
424
+ Some((open_tag.start(), close_end, src.to_string(), props, slot_content))
425
+ }).collect();
426
+
427
+ // Pick the rightmost match that has a cached component and apply it, then re-scan.
428
+ let mut sorted = matches;
429
+ sorted.sort_by(|a, b| b.0.cmp(&a.0));
430
+
431
+ let mut applied = false;
432
+ for (start, end, src, props, slot_content) in sorted {
433
+ let normalized_src = if src.starts_with("http://") || src.starts_with("https://") {
434
+ src.to_string()
345
435
  } else {
346
- format!("/{}", without_prefix)
347
- }
348
- };
349
-
350
- // Get fully resolved component from cache
351
- if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
352
- // Replace props
353
- for (prop_name, prop_value) in props {
354
- let prop_binding = format!("@[{}]", prop_name);
355
- replacement = replacement.replace(&prop_binding, prop_value);
356
- }
436
+ let without_prefix = src.trim_start_matches("./");
437
+ if without_prefix.starts_with('/') {
438
+ without_prefix.to_string()
439
+ } else {
440
+ format!("/{}", without_prefix)
441
+ }
442
+ };
357
443
 
358
- // Replace slots
359
- let slot_replacement = slot_content
360
- .as_ref()
361
- .filter(|s| !s.trim().is_empty())
362
- .map(|s| s.as_str())
363
- .unwrap_or("");
444
+ if let Some(mut replacement) = external_cache.get(&normalized_src).cloned() {
445
+ for (prop_name, prop_value) in &props {
446
+ let prop_binding = format!("@[{}]", prop_name);
447
+ replacement = replacement.replace(&prop_binding, prop_value);
448
+ }
364
449
 
365
- replacement = replacement.replace("<slot></slot>", slot_replacement);
366
- replacement = replacement.replace("<slot/>", slot_replacement);
367
- replacement = replacement.replace("<slot />", slot_replacement);
450
+ let slot_replacement = if slot_content.trim().is_empty() { "" } else { slot_content.as_str() };
451
+ replacement = replacement.replace("<slot></slot>", slot_replacement);
452
+ replacement = replacement.replace("<slot/>", slot_replacement);
453
+ replacement = replacement.replace("<slot />", slot_replacement);
454
+
455
+ let wrapper = format!("<component>{}</component>", replacement);
456
+ result.replace_range(start..end, &wrapper);
457
+ applied = true;
458
+ break;
459
+ }
460
+ }
368
461
 
369
- // Keep the <component> wrapper
370
- let wrapper = format!("<component>{}</component>", replacement);
371
- result.replace_range(*start..*end, &wrapper);
462
+ if !applied {
463
+ break;
372
464
  }
373
465
  }
374
466
 
@@ -440,16 +532,41 @@ fn transform_custom_tags_to_divs(content: &str, reserved_elements: &[String]) ->
440
532
  }
441
533
  }
442
534
 
535
+ // Sort by descending length so more specific tags (e.g. "accordion-content")
536
+ // are processed before shorter prefixes (e.g. "accordion"), preventing
537
+ // partial tag-name matches like <accordion([^>]*)> matching <accordion-content>
538
+ custom_tags.sort_by(|a, b| b.len().cmp(&a.len()));
539
+
540
+ // Pre-compile class attribute regex for merging existing class values
541
+ let class_attr_re = regex::Regex::new(r#"\bclass="([^"]*)""#).unwrap();
542
+
443
543
  // 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();
544
+ for tag in &custom_tags {
545
+ // Opening tag: require whitespace or end-of-tag after the tag name so that
546
+ // <accordion> does not accidentally match <accordion-content>
547
+ let open_re = regex::Regex::new(
548
+ &format!(r"<{}([\s][^>]*|)>", regex::escape(tag))
549
+ ).unwrap();
550
+
551
+ result = open_re.replace_all(&result, |caps: &regex::Captures| -> String {
552
+ let attrs = caps.get(1).map(|m| m.as_str()).unwrap_or("");
553
+
554
+ // If the element already has class="...", merge tag name with existing value
555
+ if let Some(class_cap) = class_attr_re.captures(attrs) {
556
+ let existing = class_cap.get(1).unwrap().as_str();
557
+ let merged = format!("{} {}", tag, existing);
558
+ let new_attrs = class_attr_re.replace(
559
+ attrs,
560
+ format!(r#"class="{}""#, merged.trim()).as_str(),
561
+ );
562
+ format!("<div{}>", new_attrs)
563
+ } else {
564
+ format!("<div class=\"{}\"{}>", tag, attrs)
565
+ }
566
+ }).to_string();
450
567
 
451
568
  // Closing tag: </custom-tag> -> </div>
452
- let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(&tag))).unwrap();
569
+ let close_re = regex::Regex::new(&format!(r"</{}>", regex::escape(tag))).unwrap();
453
570
  result = close_re.replace_all(&result, "</div>").to_string();
454
571
  }
455
572
 
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.1",
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,15 +52,18 @@ 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
  }
57
60
 
58
61
  const src = el.getAttribute('src');
59
62
 
60
- // Capture children and props before fetching
61
- const children = el.innerHTML.trim();
63
+ // Use pre-hydration slot content if available (saved by index.js before hydration ran),
64
+ // otherwise fall back to current innerHTML (e.g. runtime-only usage without boot).
65
+ const children = (el._vibeSlotContent !== undefined ? el._vibeSlotContent : el.innerHTML).trim();
66
+ delete el._vibeSlotContent;
62
67
  const props = {};
63
68
  Array.from(el.attributes).forEach((attr) => {
64
69
  if (attr.name !== 'src') {
@@ -131,21 +136,21 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
131
136
  const thisRegex = /@\[this\.(\w+)\]/g;
132
137
 
133
138
  // Rewrite in text nodes
134
- Array.from(element.childNodes).forEach(node => {
139
+ Array.from(element.childNodes).forEach((node) => {
135
140
  if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
136
141
  node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
137
142
  }
138
143
  });
139
144
 
140
145
  // Rewrite in attributes
141
- Array.from(element.attributes || []).forEach(attr => {
146
+ Array.from(element.attributes || []).forEach((attr) => {
142
147
  if (attr.value.includes('@[this.')) {
143
148
  attr.value = attr.value.replace(thisRegex, `@[${componentId}.$1]`);
144
149
  }
145
150
  });
146
151
 
147
152
  // Recurse into children
148
- Array.from(element.children).forEach(child => {
153
+ Array.from(element.children).forEach((child) => {
149
154
  rewriteThisBindings(child);
150
155
  });
151
156
  };
@@ -195,9 +200,10 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
195
200
  // Check if element still has a parent (might have been removed during fetch)
196
201
  if (el.parentNode) {
197
202
  // 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');
203
+ const newWrapper =
204
+ el.tagName === 'DIV'
205
+ ? document.createElement('div')
206
+ : document.createElement('component');
201
207
 
202
208
  if (el.tagName === 'DIV') {
203
209
  newWrapper.className = 'component';
@@ -207,16 +213,8 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
207
213
  el.replaceWith(newWrapper);
208
214
  debugLog(PHASE_FETCH, src, debug);
209
215
 
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
- }
216
+ // Let MutationObserver handle the mutation naturally
217
+ // It will call processMutations, which will call processComponent for the next component
220
218
  }
221
219
  })
222
220
  .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: {} };
@@ -453,6 +454,13 @@ const main = (s, config = {}, stringSelector = '') => {
453
454
  restoreMarkersFromManifest(rootElement, cloneForRestoration, hyperspeedTree);
454
455
  }
455
456
 
457
+ // Save raw slot content of fetched components before hydration replaces @[...] markers.
458
+ // component.js captures el.innerHTML when resolving — if hydration already ran, the
459
+ // binding syntax is gone and the resolved component won't be reactive.
460
+ rootElement.querySelectorAll('component[src], div.component[src]').forEach(el => {
461
+ el._vibeSlotContent = el.innerHTML;
462
+ });
463
+
456
464
  // Runtime parses DOM (which now has restored markers if hyperspeed was used)
457
465
  let parsedTree = parse(rootElement);
458
466
 
@@ -497,6 +505,7 @@ const main = (s, config = {}, stringSelector = '') => {
497
505
  const hooks = {
498
506
  afterUpdate: [],
499
507
  afterDomMutation: [],
508
+ ready: [],
500
509
  };
501
510
 
502
511
  // Extract plain values from proxy (removes proxy wrappers)
@@ -950,6 +959,15 @@ const main = (s, config = {}, stringSelector = '') => {
950
959
  if (shouldCleanup(rootElement)) {
951
960
  cleanup(rootElement, debug);
952
961
  cleanupExecuted = true;
962
+
963
+ // Fire ready hook after cleanup completes
964
+ hooks.ready.forEach((callback) => {
965
+ try {
966
+ callback();
967
+ } catch (error) {
968
+ console.error('[vibe] Error in ready hook:', error);
969
+ }
970
+ });
953
971
  }
954
972
  };
955
973