@ape-egg/vibe 2.1.11 → 2.1.13
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 +14 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +14 -2
- package/compiler/src/compiler/iteration_optimizer.rs +28 -0
- package/compiler/src/compiler/manifest_builder.rs +11 -1
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/name_binding_protect.rs +207 -0
- package/package.json +1 -1
- package/runtime/hydrate.js +7 -0
- package/runtime/index.js +54 -2
- package/runtime/iterate.js +11 -0
- package/runtime/parse.js +8 -0
- package/runtime/utils.js +30 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.1.13] - 2026-06-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Compiler 1.9.7 → 1.9.8 — name-bindings whose expression contains whitespace were torn apart by the HTML parser** (`compiler/src/compiler/name_binding_protect.rs` (new), `compile.rs`, `manifest_builder.rs`, `iteration_optimizer.rs`; `runtime/parse.js`, `iterate.js`, `hydrate.js`) — a name-binding sits in attribute-*name* position (`<icon @[element]>`), and HTML parsers split an attribute token on whitespace. So once a prop inlined an expression that has spaces (`@[EQUIPMENT(item, true).element]`), html5ever (and the browser) tore it into broken attributes and dropped the binding from the manifest — the element silently kept its fallback in compiled mode while non-compiled mode worked. The compiler now relocates every whitespace-bearing name-binding into a single verbatim value attribute `data-vibe-namebind="@[expr]…"` *before* any parse round-trip (value attributes survive byte-for-byte); the manifest builder and iteration optimizer read the expression back out of it, and the runtime restores the `@[expr]` name-binding form (`parse.js`, `iterate.js`) and strips the transport attribute on hydrate (`hydrate.js`). Whitespace-free name-bindings are untouched. Tests: `tests/compiler/name-binding-whitespace`, `tests/e2e/name-binding-prop`, `name-bindings`.
|
|
8
|
+
- **Compiler 1.9.7 → 1.9.8 — `--minify` deleted significant whitespace between inline elements** (`compiler/src/compiler/compile.rs`) — minification collapsed `>\s+<` to `><`, removing whitespace between tags. For inline content that space is significant: the browser (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space, so a status chip glued onto its following word (`💠Concussion`) in compiled+minified mode. Minify now collapses inter-element whitespace to a *single space* rather than deleting it — matching both the browser's own collapsing and the non-minified output; where the space is insignificant (between block/table/list/head elements) the parser discards it anyway. Repro: `tests/compiler/minify-significant-whitespace`.
|
|
9
|
+
- **Name-bindings inside an iteration couldn't resolve props stashed in the iter-props global** (`runtime/utils.js`) — `resolveCaseInsensitivePath` only walked the diff state, but a name-binding inside an `<!-- each -->` reads its props from a global stash (`window.__vibeiterprops._p0.statusKey`), so a state-only walk never reached it and the camelCase leaf the parser lowercased stayed unresolved (the status-chip gray-icon parity bug — resolved in compiled mode, not runtime). It now resolves the path's root against the same scope and order `evalInScope` uses — the diff state including scoped loop aliases (via `ownKeysOf`), then globals — so iter-prop stashes and scoped aliases resolve case-insensitively. Covered by `tests/unit/utils.test.js`.
|
|
10
|
+
|
|
11
|
+
## [2.1.12] - 2026-06-22
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Nodes resolving inside slot-projected branch/iteration content were orphaned from the reactive tree** (`runtime/index.js`) — `navigateTree` only walks a node's `children`, but content projected across a `<slot>` boundary (a conditional branch's or an iteration instance's slotted content) is registered in the flat manifest under a path that isn't reachable through `children` — the slot node's children don't include the projected subtree. So when a `<component src>` (or any node) mounted inside such content, `navigateTree` returned null: on add, the resolved subtree never linked into the reactive tree (so it wasn't reactive); on remove, the stale tree entry lingered beside its replacement. A new `findNodeByElement` does an identity search across the *full* tree — plain `children`, conditional branch trees (`runtime.activeInstance.parsedTree`), and iteration instance trees (`runtime.instances`) — and is used as a fallback at both the add and remove sites when path navigation can't reach the parent, so the subtree links in and cleans up regardless of slot/branch/iteration projection.
|
|
16
|
+
|
|
3
17
|
## [2.1.11] - 2026-06-22
|
|
4
18
|
|
|
5
19
|
### Fixed
|
|
Binary file
|
|
Binary file
|
package/compiler/src/Cargo.lock
CHANGED
package/compiler/src/Cargo.toml
CHANGED
|
@@ -968,8 +968,12 @@ impl Compiler {
|
|
|
968
968
|
use crate::compiler::component_tagger::ComponentTagger;
|
|
969
969
|
use crate::compiler::value_stamper::ValueStamper;
|
|
970
970
|
|
|
971
|
+
// Relocate whitespace-bearing name-bindings into a value-attribute BEFORE any
|
|
972
|
+
// html5ever round-trip splits them on their spaces (see name_binding_protect).
|
|
973
|
+
let protected = crate::compiler::name_binding_protect::protect(html);
|
|
974
|
+
|
|
971
975
|
// Tag components with deterministic IDs and structure state
|
|
972
|
-
let tagged = ComponentTagger::tag_components(
|
|
976
|
+
let tagged = ComponentTagger::tag_components(&protected, &source_root.to_path_buf())?;
|
|
973
977
|
let html = &tagged.html; // Use modified HTML with data-vibe-component-id attributes
|
|
974
978
|
let state = tagged.state;
|
|
975
979
|
|
|
@@ -2181,9 +2185,17 @@ fn minify_html(html: &str) -> String {
|
|
|
2181
2185
|
}
|
|
2182
2186
|
}
|
|
2183
2187
|
|
|
2188
|
+
// Collapse whitespace between tags to a SINGLE space — never delete it.
|
|
2189
|
+
// Inter-element whitespace is significant for inline content: the browser
|
|
2190
|
+
// (and the non-compiled runtime) render `<em>a</em> <em>b</em>` with a space,
|
|
2191
|
+
// so dropping it (`><`) glued words onto preceding inline elements (the
|
|
2192
|
+
// status-chip "💠Concussion" parity bug). One space matches both the browser's
|
|
2193
|
+
// own collapsing and the non-minified/runtime output; where the space is
|
|
2194
|
+
// insignificant (between block/table/list/head elements) the parser discards
|
|
2195
|
+
// it anyway, exactly as it does for the non-minified newline.
|
|
2184
2196
|
let result = regex::Regex::new(r">\s+<")
|
|
2185
2197
|
.unwrap()
|
|
2186
|
-
.replace_all(&result, "
|
|
2198
|
+
.replace_all(&result, "> <")
|
|
2187
2199
|
.to_string();
|
|
2188
2200
|
|
|
2189
2201
|
result.trim().to_string()
|
|
@@ -157,6 +157,28 @@ const VALUE_ATTRS: &[&str] = &[
|
|
|
157
157
|
|
|
158
158
|
/// Whether an attribute keeps its string value (vs boolean coercion).
|
|
159
159
|
/// Mirrors isValueStyleAttr in runtime/iterate.js.
|
|
160
|
+
/// Turn ` data-vibe-namebind="@[A]@[B]"` back into ` @[A]="" @[B]=""` so each relocated
|
|
161
|
+
/// binding is rendered as a name-binding (` resolvedName=""`) rather than having its
|
|
162
|
+
/// resolved value written into the transport attribute. See name_binding_protect.
|
|
163
|
+
fn restore_relocated_name_bindings(template: &str) -> String {
|
|
164
|
+
let wrapper = Regex::new(
|
|
165
|
+
r#"\s+data-vibe-namebind="((?:@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)""#,
|
|
166
|
+
)
|
|
167
|
+
.unwrap();
|
|
168
|
+
let one = Regex::new(r#"@\[(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]"#).unwrap();
|
|
169
|
+
wrapper
|
|
170
|
+
.replace_all(template, |caps: ®ex::Captures| {
|
|
171
|
+
let mut s = String::new();
|
|
172
|
+
for m in one.find_iter(&caps[1]) {
|
|
173
|
+
s.push(' ');
|
|
174
|
+
s.push_str(m.as_str());
|
|
175
|
+
s.push_str("=\"\"");
|
|
176
|
+
}
|
|
177
|
+
s
|
|
178
|
+
})
|
|
179
|
+
.to_string()
|
|
180
|
+
}
|
|
181
|
+
|
|
160
182
|
fn is_value_style_attr(name: &str) -> bool {
|
|
161
183
|
VALUE_ATTRS.contains(&name)
|
|
162
184
|
|| name.starts_with("data-")
|
|
@@ -208,6 +230,12 @@ fn compile_template_to_batch_fn(
|
|
|
208
230
|
/// Anything the runtime clone+hydrate path renders, this output has to render
|
|
209
231
|
/// identically — it is the compiled twin of compileBatchFn in runtime/iterate.js.
|
|
210
232
|
fn emit_template_literal(template: &str) -> String {
|
|
233
|
+
// Restore name-bindings the compiler relocated into `data-vibe-namebind` (their
|
|
234
|
+
// expression has whitespace, so it could not be an HTML attribute name) back to the
|
|
235
|
+
// `@[expr]=""` form the binding pass below renders as ` resolvedName=""`. The compiled
|
|
236
|
+
// twin of the same step in runtime/iterate.js compileBatchFn.
|
|
237
|
+
let template = &restore_relocated_name_bindings(template);
|
|
238
|
+
|
|
211
239
|
let each_re = Regex::new(r"<!--\s*each\s+([^\s]+)\s+as\s+([^\s,]+)(?:\s*,\s*([^\s]+))?\s*-->").unwrap();
|
|
212
240
|
let end_re = Regex::new(r"<!--\s*/each\s*-->").unwrap();
|
|
213
241
|
let attr_binding_re = Regex::new(r#"(\s)([\w-]+)="@\[([^\]]+)\]""#).unwrap();
|
|
@@ -138,8 +138,18 @@ impl ManifestBuilder {
|
|
|
138
138
|
let attr_name = attr.name.local.to_string();
|
|
139
139
|
let attr_value = attr.value.to_string();
|
|
140
140
|
|
|
141
|
+
// A relocated name-binding: its expression(s) live verbatim in the
|
|
142
|
+
// VALUE of data-vibe-namebind because whitespace barred them from
|
|
143
|
+
// attribute-name position. Treat the value as the name-binding — no
|
|
144
|
+
// case restoration needed (value attrs keep their case).
|
|
145
|
+
if attr_name == crate::compiler::name_binding_protect::NAME_BIND_ATTR {
|
|
146
|
+
while name_bindings.len() <= idx {
|
|
147
|
+
name_bindings.push(None);
|
|
148
|
+
}
|
|
149
|
+
name_bindings[idx] = Some(attr_value);
|
|
150
|
+
}
|
|
141
151
|
// Check for name bindings (binding in attribute NAME)
|
|
142
|
-
if self.binding_regex.is_match(&attr_name) {
|
|
152
|
+
else if self.binding_regex.is_match(&attr_name) {
|
|
143
153
|
// Extend vec to include this index
|
|
144
154
|
while name_bindings.len() <= idx {
|
|
145
155
|
name_bindings.push(None);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
use regex::Regex;
|
|
2
|
+
use std::sync::OnceLock;
|
|
3
|
+
|
|
4
|
+
/// The attribute a relocated name-binding is parked in. Mirrors the runtime
|
|
5
|
+
/// constant of the same name (runtime/constants.js) — the manifest builder reads
|
|
6
|
+
/// the binding back out of it, and the runtime consumes + strips it on hydrate.
|
|
7
|
+
pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
|
|
8
|
+
|
|
9
|
+
/// A name-binding lives in attribute-NAME position: `<icon @[expr]>`. HTML parsers
|
|
10
|
+
/// split an attribute token on whitespace, so once a prop is inlined into one and the
|
|
11
|
+
/// expression gains spaces — `<icon @[element]>` becoming `@[EQUIPMENT(item, true).element]`
|
|
12
|
+
/// — html5ever (and the browser) tear it into broken attributes and the binding is lost.
|
|
13
|
+
///
|
|
14
|
+
/// We relocate every whitespace-bearing name-binding into a single verbatim
|
|
15
|
+
/// value-attribute `data-vibe-namebind="@[expr]@[expr2]…"`. Value attributes are kept
|
|
16
|
+
/// byte-for-byte through parsing, so the manifest builder recovers the exact expression
|
|
17
|
+
/// and the runtime evaluates it through the same path it uses in non-compiled mode —
|
|
18
|
+
/// identical behaviour, no expression rewriting. Whitespace-free name-bindings already
|
|
19
|
+
/// survive as-is and are left untouched.
|
|
20
|
+
pub fn protect(html: &str) -> String {
|
|
21
|
+
// <script>/<style> bodies are raw text — a `<` inside them must not be read as a
|
|
22
|
+
// tag. Park them behind comment placeholders for the tag scan, restore after.
|
|
23
|
+
let (masked, raw_blocks) = mask_raw_text(html);
|
|
24
|
+
|
|
25
|
+
let out = tag_open_regex()
|
|
26
|
+
.replace_all(&masked, |caps: ®ex::Captures| {
|
|
27
|
+
let name = &caps[1];
|
|
28
|
+
let attrs = &caps[2];
|
|
29
|
+
let (kept, relocated) = relocate_unsafe(attrs);
|
|
30
|
+
if relocated.is_empty() {
|
|
31
|
+
return caps[0].to_string();
|
|
32
|
+
}
|
|
33
|
+
// Preserve a self-closing slash as the final token.
|
|
34
|
+
let kept_end_trimmed = kept.trim_end();
|
|
35
|
+
let (body, self_close) = match kept_end_trimmed.strip_suffix('/') {
|
|
36
|
+
Some(b) => (b.trim_end(), " /"),
|
|
37
|
+
None => (kept_end_trimmed, ""),
|
|
38
|
+
};
|
|
39
|
+
format!(
|
|
40
|
+
"<{}{} {}=\"{}\"{}>",
|
|
41
|
+
name, body, NAME_BIND_ATTR, relocated, self_close
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
.to_string();
|
|
45
|
+
|
|
46
|
+
restore_raw_text(&out, &raw_blocks)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Matches an opening tag and captures (name, attribute-section). Quoted values may
|
|
50
|
+
/// hold anything but their own quote; bare attribute chars are anything but `<>"'`.
|
|
51
|
+
fn tag_open_regex() -> &'static Regex {
|
|
52
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
53
|
+
RE.get_or_init(|| {
|
|
54
|
+
Regex::new(r#"<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*)>"#).unwrap()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Walk a tag's attribute section, moving each whitespace-bearing name-binding out of
|
|
59
|
+
/// the kept attributes and into the concatenated `relocated` string. Quoted attribute
|
|
60
|
+
/// values (and string literals inside an expression) are skipped so value-bindings and
|
|
61
|
+
/// `@[fn('a b')]`-style literals are never mistaken for a name-binding.
|
|
62
|
+
fn relocate_unsafe(attrs: &str) -> (String, String) {
|
|
63
|
+
let bytes = attrs.as_bytes();
|
|
64
|
+
let n = bytes.len();
|
|
65
|
+
let mut kept = String::with_capacity(n);
|
|
66
|
+
let mut relocated = String::new();
|
|
67
|
+
let mut i = 0;
|
|
68
|
+
let mut seg = 0;
|
|
69
|
+
|
|
70
|
+
while i < n {
|
|
71
|
+
let b = bytes[i];
|
|
72
|
+
|
|
73
|
+
// Skip a quoted attribute value verbatim (it may legally contain `@[…]`).
|
|
74
|
+
if b == b'"' || b == b'\'' {
|
|
75
|
+
i += 1;
|
|
76
|
+
while i < n && bytes[i] != b {
|
|
77
|
+
i += 1;
|
|
78
|
+
}
|
|
79
|
+
if i < n {
|
|
80
|
+
i += 1; // consume closing quote
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A bare name-binding in attribute-name position.
|
|
86
|
+
if b == b'@' && i + 1 < n && bytes[i + 1] == b'[' {
|
|
87
|
+
kept.push_str(&attrs[seg..i]);
|
|
88
|
+
let start = i;
|
|
89
|
+
i += 2;
|
|
90
|
+
let mut depth = 1;
|
|
91
|
+
while i < n && depth > 0 {
|
|
92
|
+
let c = bytes[i];
|
|
93
|
+
if c == b'\'' || c == b'"' {
|
|
94
|
+
i += 1;
|
|
95
|
+
while i < n && bytes[i] != c {
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
if i < n {
|
|
99
|
+
i += 1;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
match c {
|
|
104
|
+
b'[' => depth += 1,
|
|
105
|
+
b']' => depth -= 1,
|
|
106
|
+
_ => {}
|
|
107
|
+
}
|
|
108
|
+
i += 1;
|
|
109
|
+
}
|
|
110
|
+
let binding = &attrs[start..i];
|
|
111
|
+
let inner = &binding[2..binding.len() - 1];
|
|
112
|
+
if inner.bytes().any(|x| x.is_ascii_whitespace()) {
|
|
113
|
+
relocated.push_str(binding);
|
|
114
|
+
} else {
|
|
115
|
+
kept.push_str(binding);
|
|
116
|
+
}
|
|
117
|
+
seg = i;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
i += 1;
|
|
122
|
+
}
|
|
123
|
+
kept.push_str(&attrs[seg..]);
|
|
124
|
+
(kept, relocated)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fn raw_text_regex() -> &'static Regex {
|
|
128
|
+
static RE: OnceLock<Regex> = OnceLock::new();
|
|
129
|
+
RE.get_or_init(|| Regex::new(r"(?is)<(script|style)\b[^>]*>.*?</(?:script|style)>").unwrap())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn mask_raw_text(html: &str) -> (String, Vec<String>) {
|
|
133
|
+
let mut blocks = Vec::new();
|
|
134
|
+
let mut out = String::with_capacity(html.len());
|
|
135
|
+
let mut last = 0;
|
|
136
|
+
for mat in raw_text_regex().find_iter(html) {
|
|
137
|
+
out.push_str(&html[last..mat.start()]);
|
|
138
|
+
out.push_str(&format!("<!--VIBE_RAWTEXT_{}-->", blocks.len()));
|
|
139
|
+
blocks.push(html[mat.start()..mat.end()].to_string());
|
|
140
|
+
last = mat.end();
|
|
141
|
+
}
|
|
142
|
+
out.push_str(&html[last..]);
|
|
143
|
+
(out, blocks)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn restore_raw_text(html: &str, blocks: &[String]) -> String {
|
|
147
|
+
let mut out = html.to_string();
|
|
148
|
+
for (i, block) in blocks.iter().enumerate() {
|
|
149
|
+
out = out.replace(&format!("<!--VIBE_RAWTEXT_{}-->", i), block);
|
|
150
|
+
}
|
|
151
|
+
out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[cfg(test)]
|
|
155
|
+
mod tests {
|
|
156
|
+
use super::*;
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn relocates_whitespace_name_binding() {
|
|
160
|
+
let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
|
|
161
|
+
let out = protect(html);
|
|
162
|
+
assert!(
|
|
163
|
+
out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
|
|
164
|
+
"got: {}",
|
|
165
|
+
out
|
|
166
|
+
);
|
|
167
|
+
// The raw whitespace binding must no longer sit in attribute-name position.
|
|
168
|
+
assert!(!out.contains("@[EQUIPMENT(item, true).element]>"));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[test]
|
|
172
|
+
fn leaves_whitespace_free_name_binding_untouched() {
|
|
173
|
+
let html = r#"<icon @[(selectedEquipProps(uuid)).element]></icon>"#;
|
|
174
|
+
assert_eq!(protect(html), html);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#[test]
|
|
178
|
+
fn leaves_value_binding_untouched() {
|
|
179
|
+
let html = r#"<card tinted="@[fn(a, b).element]"></card>"#;
|
|
180
|
+
assert_eq!(protect(html), html);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#[test]
|
|
184
|
+
fn leaves_text_binding_untouched() {
|
|
185
|
+
let html = r#"<label>@[fn(a, b)]</label>"#;
|
|
186
|
+
assert_eq!(protect(html), html);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[test]
|
|
190
|
+
fn ignores_at_bracket_inside_script() {
|
|
191
|
+
let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
|
|
192
|
+
let out = protect(html);
|
|
193
|
+
assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
|
|
194
|
+
assert!(out.contains(r#"data-vibe-namebind="@[fn(a, b)]""#), "got: {}", out);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn relocates_multiple_on_one_element() {
|
|
199
|
+
let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
|
|
200
|
+
let out = protect(html);
|
|
201
|
+
assert!(
|
|
202
|
+
out.contains(r#"data-vibe-namebind="@[fn(a, b)]@[gn(c, d)]""#),
|
|
203
|
+
"got: {}",
|
|
204
|
+
out
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
package/package.json
CHANGED
package/runtime/hydrate.js
CHANGED
|
@@ -65,6 +65,13 @@ export default (affected, state, manifest = {}, oldState = {}) => {
|
|
|
65
65
|
} else {
|
|
66
66
|
element._vibeNameBindings.delete(nameBinding);
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// A binding relocated into `data-vibe-namebind` (clone path) has served its
|
|
70
|
+
// transport purpose once the real attribute is set — drop it so the rendered
|
|
71
|
+
// DOM matches the batch path, which never emits it.
|
|
72
|
+
if (element.hasAttribute('data-vibe-namebind')) {
|
|
73
|
+
element.removeAttribute('data-vibe-namebind');
|
|
74
|
+
}
|
|
68
75
|
} catch (e) {
|
|
69
76
|
console.error('Error hydrating name binding:', e);
|
|
70
77
|
}
|
package/runtime/index.js
CHANGED
|
@@ -81,6 +81,48 @@ const navigateTree = (tree, path) => {
|
|
|
81
81
|
return path.split('.').filter(k => k).reduce((node, key) => node?.children?.[key], tree);
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
// Locate the tree node whose `.element` is `target`, searching the full tree:
|
|
85
|
+
// plain `children`, plus conditional branch trees (`runtime.activeInstance`)
|
|
86
|
+
// and iteration instance trees (`runtime.instances`). navigateTree only walks
|
|
87
|
+
// `children`, so it can't reach content projected across a <slot> boundary — a
|
|
88
|
+
// conditional branch's slotted content is registered in the flat manifest by
|
|
89
|
+
// path, but that path isn't navigable through `children` (the slot node's
|
|
90
|
+
// children don't include the projected subtree). When a <component src> resolves
|
|
91
|
+
// inside such content, navigateTree returns null and the resolved subtree would
|
|
92
|
+
// be orphaned from the reactive tree. This identity search recovers the real
|
|
93
|
+
// parent node so the subtree links in regardless of slot/branch projection.
|
|
94
|
+
const findNodeByElement = (tree, target) => {
|
|
95
|
+
if (!target) return null;
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const walk = (node) => {
|
|
98
|
+
if (!node || typeof node !== 'object' || seen.has(node)) return null;
|
|
99
|
+
seen.add(node);
|
|
100
|
+
if (node.element === target) return node;
|
|
101
|
+
if (node.children) {
|
|
102
|
+
for (const key in node.children) {
|
|
103
|
+
const hit = walk(node.children[key]);
|
|
104
|
+
if (hit) return hit;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const branchTree = node.runtime?.activeInstance?.parsedTree;
|
|
108
|
+
if (branchTree) {
|
|
109
|
+
const hit = walk(branchTree);
|
|
110
|
+
if (hit) return hit;
|
|
111
|
+
}
|
|
112
|
+
const instances = node.runtime?.instances;
|
|
113
|
+
if (instances) {
|
|
114
|
+
for (let i = 0; i < instances.length; i++) {
|
|
115
|
+
if (instances[i]?.tree) {
|
|
116
|
+
const hit = walk(instances[i].tree);
|
|
117
|
+
if (hit) return hit;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
};
|
|
123
|
+
return walk(tree);
|
|
124
|
+
};
|
|
125
|
+
|
|
84
126
|
// Get or create a node in the tree at the given path
|
|
85
127
|
const ensureNode = (tree, path) => {
|
|
86
128
|
const keys = path.split('.').filter(k => k);
|
|
@@ -853,7 +895,12 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
853
895
|
const name = dotPath.pop();
|
|
854
896
|
const parentDotAnnotation = dotPath.join('.');
|
|
855
897
|
|
|
856
|
-
|
|
898
|
+
// Identity fallback mirrors the added-node path: a parent inside
|
|
899
|
+
// slot-projected branch content isn't reachable via navigateTree's
|
|
900
|
+
// `children` walk, so resolve it by element identity instead — otherwise
|
|
901
|
+
// the removed node's stale tree entry lingers alongside its replacement.
|
|
902
|
+
const picked =
|
|
903
|
+
navigateTree(parsedTree, parentDotAnnotation) || findNodeByElement(parsedTree, target);
|
|
857
904
|
|
|
858
905
|
// If we can't navigate to the parent, skip
|
|
859
906
|
if (!picked || !picked.element) return;
|
|
@@ -912,7 +959,12 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
912
959
|
// registration since there's no tree branch to attach to.)
|
|
913
960
|
if (entry) {
|
|
914
961
|
const [dotAnnotation] = entry;
|
|
915
|
-
|
|
962
|
+
// navigateTree walks `children` only; when the parent lives in a
|
|
963
|
+
// conditional branch's slot-projected content its manifest path isn't
|
|
964
|
+
// navigable that way (see findNodeByElement). Fall back to an identity
|
|
965
|
+
// search so the resolved subtree still links into the reactive tree.
|
|
966
|
+
const picked =
|
|
967
|
+
navigateTree(parsedTree, dotAnnotation) || findNodeByElement(parsedTree, target);
|
|
916
968
|
|
|
917
969
|
if (picked) {
|
|
918
970
|
if (!picked.element) {
|
package/runtime/iterate.js
CHANGED
|
@@ -193,6 +193,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
|
|
|
193
193
|
|
|
194
194
|
let templateHtml = tplClone.innerHTML.trim();
|
|
195
195
|
|
|
196
|
+
// A name-binding the compiler relocated into `data-vibe-namebind` (its expression has
|
|
197
|
+
// whitespace, so it can't be an HTML attribute name) — restore the `@[expr]=""` form
|
|
198
|
+
// the name-binding pass below understands. Safe in this template STRING: only the
|
|
199
|
+
// resolved value (no whitespace) ever reaches innerHTML. Mirrors the clone path, which
|
|
200
|
+
// reads the same binding from the manifest's nameBindings.
|
|
201
|
+
templateHtml = templateHtml.replace(
|
|
202
|
+
/\sdata-vibe-namebind="((?:@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\])+)"/g,
|
|
203
|
+
(_, bindings) =>
|
|
204
|
+
bindings.replace(/@\[(?:[^[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+\]/g, (b) => ' ' + b + '=""'),
|
|
205
|
+
);
|
|
206
|
+
|
|
196
207
|
// Rewrite `this.X` only inside @[…] expressions so literal occurrences in
|
|
197
208
|
// text content (e.g. a code example explaining `this.foo`) aren't mangled.
|
|
198
209
|
// Mirrors evalInScope's behavior in the clone path.
|
package/runtime/parse.js
CHANGED
|
@@ -42,6 +42,14 @@ const captureAttributeBindings = (element, aliasSet) => {
|
|
|
42
42
|
const attr = element.attributes[j];
|
|
43
43
|
BINDING_REGEX.lastIndex = 0;
|
|
44
44
|
|
|
45
|
+
// A name-binding the compiler relocated into data-vibe-namebind because its
|
|
46
|
+
// expression has whitespace (barred from attribute-name position): the value
|
|
47
|
+
// holds the verbatim @[expr](s). Treat it as a name binding, not a value binding.
|
|
48
|
+
if (attr.name === 'data-vibe-namebind') {
|
|
49
|
+
nameBindings.push(attr.value);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
// Attribute name itself contains a binding (e.g. <icon @[section.icon]>).
|
|
46
54
|
if (BINDING_REGEX.test(attr.name)) {
|
|
47
55
|
nameBindings.push(attr.name);
|
package/runtime/utils.js
CHANGED
|
@@ -231,8 +231,31 @@ export const resolveCaseInsensitivePath = (state, path) => {
|
|
|
231
231
|
path = path.replace(/[()]/g, '');
|
|
232
232
|
if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(path)) return undefined;
|
|
233
233
|
}
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
let segments = path.split('.');
|
|
235
|
+
|
|
236
|
+
// Resolve the root against the SAME scope evalInScope reaches, in its order:
|
|
237
|
+
// the diff state (including scoped loop aliases — see ownKeysOf below), then
|
|
238
|
+
// globals. A name-binding inside an iteration reads its props from a global
|
|
239
|
+
// stash — `window.__vibeiterprops._p0.statusKey` — so a state-only walk can
|
|
240
|
+
// never reach it, and the camelCase leaf the parser lowercased stays lost
|
|
241
|
+
// (the status-chip gray-icon parity bug: resolved in compiled, not runtime).
|
|
242
|
+
const root = segments[0];
|
|
243
|
+
const rootInState =
|
|
244
|
+
state != null &&
|
|
245
|
+
(Reflect.has(Object(state), root) ||
|
|
246
|
+
ownKeysOf(Object(state)).some((k) => k.toLowerCase() === root.toLowerCase()));
|
|
247
|
+
let current;
|
|
248
|
+
if (rootInState) {
|
|
249
|
+
current = state;
|
|
250
|
+
} else if (root === 'window' || root === 'globalThis') {
|
|
251
|
+
current = globalThis;
|
|
252
|
+
segments = segments.slice(1);
|
|
253
|
+
} else if (typeof globalThis !== 'undefined' && Reflect.has(globalThis, root)) {
|
|
254
|
+
current = globalThis;
|
|
255
|
+
} else {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
236
259
|
for (const seg of segments) {
|
|
237
260
|
if (current == null) return undefined;
|
|
238
261
|
// Direct first — handles proxies (scoped iteration state) and plain objects.
|
|
@@ -240,8 +263,11 @@ export const resolveCaseInsensitivePath = (state, path) => {
|
|
|
240
263
|
current = current[seg];
|
|
241
264
|
continue;
|
|
242
265
|
}
|
|
243
|
-
if (typeof current !== 'object') return undefined;
|
|
244
|
-
|
|
266
|
+
if (typeof current !== 'object' && typeof current !== 'function') return undefined;
|
|
267
|
+
// Read keys from the SAME source evalInScope does (ownKeysOf), so a scoped
|
|
268
|
+
// loop alias — present in the proxy's precomputed key list but hidden from
|
|
269
|
+
// Object.keys/Reflect.has — resolves here too.
|
|
270
|
+
const ci = ownKeysOf(current).find((k) => k.toLowerCase() === seg.toLowerCase());
|
|
245
271
|
if (!ci) return undefined;
|
|
246
272
|
current = current[ci];
|
|
247
273
|
}
|