@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.
- package/CHANGELOG.md +44 -1
- package/README.md +19 -8
- package/boot.js +11 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +38 -12
- package/compiler/src/compiler/component_tagger.rs +22 -3
- package/compiler/src/compiler/value_stamper.rs +75 -5
- package/compiler/src/compiler/watcher.rs +69 -2
- package/compiler/src/config.rs +10 -0
- package/compiler/src/main.rs +39 -15
- package/compiler/src/parser/html.rs +61 -9
- package/index.js +34 -5
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/runtime/cleanup.js +0 -9
- package/runtime/component.js +14 -18
- package/runtime/index.js +13 -2
- package/runtime/iterate.js +63 -15
- package/runtime/parse.js +3 -2
- package/runtime/pre-compiled-manifest.js +111 -58
package/compiler/src/main.rs
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
println!(" {}: {}", format!("{:<
|
|
205
|
-
println!(" {}: {}", format!("{:<
|
|
206
|
-
println!(" {}: {}", format!("{:<
|
|
207
|
-
println!(" {}: {}", format!("{:<
|
|
208
|
-
println!(" {}: {}", format!("{:<
|
|
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
|
-
|
|
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:
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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: ®ex::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(
|
|
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 {
|
|
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 -
|
|
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
|
-
//
|
|
60
|
+
// Queue boot in microtask to allow all component scripts to register
|
|
35
61
|
ensureBoot();
|
|
36
62
|
|
|
37
|
-
return
|
|
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
package/runtime/cleanup.js
CHANGED
|
@@ -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
|
};
|
package/runtime/component.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
//
|
|
211
|
-
//
|
|
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
|
|
package/runtime/iterate.js
CHANGED
|
@@ -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() ===
|
|
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
|
-
//
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
firstElement
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
},
|