@ape-egg/vibe 1.7.2 → 1.8.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 +51 -0
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +85 -496
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +3 -11
- package/package.json +1 -1
- package/runtime/affected.js +18 -0
- package/runtime/component.js +147 -79
- package/runtime/conditionals.js +88 -7
- package/runtime/constants.js +7 -5
- package/runtime/index.js +65 -14
- package/runtime/iterate.js +119 -59
- package/runtime/iteration-utils.js +5 -1
- package/runtime/parse.js +1 -0
- package/runtime/pre-compiled-iterations.js +34 -21
- package/runtime/state.js +21 -5
- package/runtime/utils.js +24 -5
package/compiler/src/Cargo.toml
CHANGED
|
@@ -20,7 +20,7 @@ markup5ever = "0.12"
|
|
|
20
20
|
thiserror = "1.0"
|
|
21
21
|
regex = "1.10"
|
|
22
22
|
colored = "2.1"
|
|
23
|
-
|
|
23
|
+
ureq = { version = "2", features = ["tls"] }
|
|
24
24
|
glob = "0.3"
|
|
25
25
|
rquickjs = "0.6"
|
|
26
26
|
swc_common = "=0.40.1"
|
|
@@ -1422,17 +1422,9 @@ impl Compiler {
|
|
|
1422
1422
|
|
|
1423
1423
|
/// Fetch external component without caching (returns raw content)
|
|
1424
1424
|
fn fetch_external_component_raw(&self, url: &str) -> Result<String, String> {
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if !response.status().is_success() {
|
|
1429
|
-
return Err(format!("HTTP {} - {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown")));
|
|
1430
|
-
}
|
|
1431
|
-
match response.text() {
|
|
1432
|
-
Ok(content) => Ok(content),
|
|
1433
|
-
Err(e) => Err(format!("Failed to read response body: {}", e)),
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1425
|
+
match ureq::get(url).call() {
|
|
1426
|
+
Ok(response) => response.into_string().map_err(|e| format!("Failed to read response body: {}", e)),
|
|
1427
|
+
Err(ureq::Error::Status(code, response)) => Err(format!("HTTP {} - {}", code, response.status_text().to_string())),
|
|
1436
1428
|
Err(e) => Err(format!("Failed to fetch: {}", e)),
|
|
1437
1429
|
}
|
|
1438
1430
|
}
|
package/package.json
CHANGED
package/runtime/affected.js
CHANGED
|
@@ -35,6 +35,24 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
|
|
|
35
35
|
// Array didn't change, but check for affected elements inside iteration instances
|
|
36
36
|
// (e.g., when tutorialProgress changes, need to update checkmarks in menu items)
|
|
37
37
|
if (tree.runtime.instances) {
|
|
38
|
+
// Compiled iterations have instances without tree/scopedState — the batch function
|
|
39
|
+
// may reference global state keys (e.g., `selectedCategory` in a button binding).
|
|
40
|
+
// If any state key changed, trigger a rebuild so the batch function re-evaluates.
|
|
41
|
+
const hasCompiledInstances = tree.compiled && tree.runtime.instances.length > 0
|
|
42
|
+
&& !tree.runtime.instances[0].tree;
|
|
43
|
+
|
|
44
|
+
if (hasCompiledInstances) {
|
|
45
|
+
const isInitialHydration = state === newState;
|
|
46
|
+
if (!isInitialHydration) {
|
|
47
|
+
const stateKeys = Object.keys(state);
|
|
48
|
+
const hasChangedKey = stateKeys.some(k => state[k] !== newState[k]);
|
|
49
|
+
if (hasChangedKey) {
|
|
50
|
+
affected.push({ type: 'iteration', node: tree, changeType: 'array' });
|
|
51
|
+
return affected;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
for (const instance of tree.runtime.instances) {
|
|
39
57
|
if (instance.tree && instance.scopedState) {
|
|
40
58
|
// Use the instance's scoped state (includes item, index, etc.)
|
package/runtime/component.js
CHANGED
|
@@ -28,36 +28,31 @@ export const abortComponentFetch = (element) => {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return;
|
|
31
|
+
// Check if an element is nested inside another unprocessed component[src]
|
|
32
|
+
const isNestedInUnprocessedComponent = (el, rootElement) => {
|
|
33
|
+
let parent = el.parentElement;
|
|
34
|
+
while (parent && parent !== rootElement) {
|
|
35
|
+
if (
|
|
36
|
+
(parent.tagName === 'COMPONENT' || parent.classList?.contains('component')) &&
|
|
37
|
+
parent.hasAttribute('src')
|
|
38
|
+
) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
parent = parent.parentElement;
|
|
43
42
|
}
|
|
43
|
+
return false;
|
|
44
|
+
};
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Skip if component is dehydrated (vibe-dehydrate attribute or class)
|
|
46
|
+
// Process a single component element: fetch HTML, execute scripts, replace DOM
|
|
47
|
+
const processSingle = (el, debug) => {
|
|
48
|
+
// Skip dehydrated components
|
|
49
49
|
if (el.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || el.classList.contains(DEHYDRATE_CLASS_OR_ATTR)) {
|
|
50
|
-
|
|
51
|
-
if (componentElements.length > 1) {
|
|
52
|
-
// Process next component
|
|
53
|
-
processComponent(rootElement, onComplete, config);
|
|
54
|
-
} else {
|
|
55
|
-
// Defer onComplete to give user code a chance to register listeners
|
|
56
|
-
if (onComplete) queueMicrotask(() => onComplete());
|
|
57
|
-
}
|
|
58
|
-
return;
|
|
50
|
+
return Promise.resolve();
|
|
59
51
|
}
|
|
60
52
|
|
|
53
|
+
// Skip if fetch already in flight for this element
|
|
54
|
+
if (pendingFetches.has(el)) return Promise.resolve();
|
|
55
|
+
|
|
61
56
|
const src = el.getAttribute('src');
|
|
62
57
|
|
|
63
58
|
// Use pre-hydration slot content if available (saved by index.js before hydration ran),
|
|
@@ -75,7 +70,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
75
70
|
const controller = new AbortController();
|
|
76
71
|
pendingFetches.set(el, controller);
|
|
77
72
|
|
|
78
|
-
fetch(src, { signal: controller.signal })
|
|
73
|
+
return fetch(src, { signal: controller.signal })
|
|
79
74
|
.then((r) => r.text())
|
|
80
75
|
.then((html) => {
|
|
81
76
|
// Parse HTML in temporary container to process component scripts
|
|
@@ -84,13 +79,41 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
84
79
|
|
|
85
80
|
// Process any <script type="module"> elements
|
|
86
81
|
const moduleScripts = temp.querySelectorAll('script[type="module"]');
|
|
87
|
-
moduleScripts.forEach((script) => {
|
|
88
|
-
let scriptContent = script.textContent?.trim() || '';
|
|
89
|
-
if (!scriptContent) return;
|
|
90
82
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
// Process each script — collect async tasks if any have imports
|
|
84
|
+
const asyncTasks = [];
|
|
85
|
+
|
|
86
|
+
for (const script of moduleScripts) {
|
|
87
|
+
let scriptContent = script.textContent?.trim() || '';
|
|
88
|
+
if (!scriptContent) continue;
|
|
89
|
+
|
|
90
|
+
// Strip `import component from '...'` — Vibe injects the contextual
|
|
91
|
+
// component() function as a parameter (it needs access to the temp DOM)
|
|
92
|
+
scriptContent = scriptContent.replace(/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g, '');
|
|
93
|
+
|
|
94
|
+
// Check for remaining imports that need rewriting
|
|
95
|
+
const hasImports = /import\s/.test(scriptContent);
|
|
96
|
+
|
|
97
|
+
if (hasImports) {
|
|
98
|
+
// Rewrite remaining imports to dynamic await import()
|
|
99
|
+
// Order matters: default → named → namespace → side-effect (most specific first)
|
|
100
|
+
scriptContent = scriptContent.replace(
|
|
101
|
+
/import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
102
|
+
'const $1 = (await import($2)).default;'
|
|
103
|
+
);
|
|
104
|
+
scriptContent = scriptContent.replace(
|
|
105
|
+
/import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
106
|
+
'const {$1} = await import($2);'
|
|
107
|
+
);
|
|
108
|
+
scriptContent = scriptContent.replace(
|
|
109
|
+
/import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
|
|
110
|
+
'const $1 = await import($2);'
|
|
111
|
+
);
|
|
112
|
+
scriptContent = scriptContent.replace(
|
|
113
|
+
/import\s+(['"][^'"]+['"])\s*;?/g,
|
|
114
|
+
'await import($1);'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
94
117
|
|
|
95
118
|
// Generate component ID for this instance
|
|
96
119
|
const componentId = generateComponentId();
|
|
@@ -122,10 +145,16 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
122
145
|
};
|
|
123
146
|
|
|
124
147
|
// Execute script with component() function in scope
|
|
125
|
-
// Use Function constructor to provide 'component' as a parameter
|
|
126
148
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
149
|
+
if (hasImports) {
|
|
150
|
+
// Async execution for scripts with imports
|
|
151
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
152
|
+
asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
|
|
153
|
+
} else {
|
|
154
|
+
// Synchronous execution for scripts without imports (preserves boot timing)
|
|
155
|
+
const executeFn = new Function('component', scriptContent);
|
|
156
|
+
executeFn(componentFn);
|
|
157
|
+
}
|
|
129
158
|
} catch (e) {
|
|
130
159
|
console.warn('[vibe] Failed to execute component script:', e);
|
|
131
160
|
}
|
|
@@ -166,56 +195,67 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
166
195
|
|
|
167
196
|
// Remove script from temp (we executed it manually)
|
|
168
197
|
script.remove();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// Get transformed HTML from temp container (scripts removed)
|
|
172
|
-
let transformedHtml = temp.innerHTML;
|
|
173
|
-
|
|
174
|
-
// Replace props
|
|
175
|
-
Object.entries(props).forEach(([propName, propValue]) => {
|
|
176
|
-
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
177
|
-
|
|
178
|
-
if (bindingMatch) {
|
|
179
|
-
// Reactive prop: replace @[propName] with @[path]
|
|
180
|
-
const path = bindingMatch[1];
|
|
181
|
-
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
182
|
-
transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
|
|
183
|
-
} else {
|
|
184
|
-
// Static prop: replace @[propName] with literal value
|
|
185
|
-
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
186
|
-
transformedHtml = transformedHtml.replace(propPattern, propValue);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// Replace <slot></slot> with children
|
|
191
|
-
if (children) {
|
|
192
|
-
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
193
|
-
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
//
|
|
197
|
-
|
|
200
|
+
// Finalize: props, slots, DOM replacement
|
|
201
|
+
const finalize = () => {
|
|
202
|
+
// Get transformed HTML from temp container (scripts removed)
|
|
203
|
+
let transformedHtml = temp.innerHTML;
|
|
204
|
+
|
|
205
|
+
// Replace props
|
|
206
|
+
Object.entries(props).forEach(([propName, propValue]) => {
|
|
207
|
+
const bindingMatch = propValue.match(/^@\[(.+)\]$/);
|
|
208
|
+
|
|
209
|
+
if (bindingMatch) {
|
|
210
|
+
// Reactive prop: replace @[propName] with @[path]
|
|
211
|
+
const path = bindingMatch[1];
|
|
212
|
+
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
213
|
+
transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
|
|
214
|
+
} else {
|
|
215
|
+
// Static prop: replace @[propName] with literal value
|
|
216
|
+
const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
|
|
217
|
+
transformedHtml = transformedHtml.replace(propPattern, propValue);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
198
220
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const newWrapper =
|
|
204
|
-
el.tagName === 'DIV'
|
|
205
|
-
? document.createElement('div')
|
|
206
|
-
: document.createElement('component');
|
|
207
|
-
|
|
208
|
-
if (el.tagName === 'DIV') {
|
|
209
|
-
newWrapper.className = 'component';
|
|
221
|
+
// Replace <slot></slot> with children
|
|
222
|
+
if (children) {
|
|
223
|
+
transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
|
|
224
|
+
transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, children);
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
debugLog(PHASE_FETCH, src, debug);
|
|
227
|
+
// Clean up pending fetch tracker
|
|
228
|
+
pendingFetches.delete(el);
|
|
215
229
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
230
|
+
// Replace with clean component wrapper (no src, no props)
|
|
231
|
+
// Check if element still has a parent (might have been removed during fetch)
|
|
232
|
+
if (el.parentNode) {
|
|
233
|
+
// Create clean wrapper element (preserve tag type: component or div.component)
|
|
234
|
+
const newWrapper =
|
|
235
|
+
el.tagName === 'DIV'
|
|
236
|
+
? document.createElement('div')
|
|
237
|
+
: document.createElement('component');
|
|
238
|
+
|
|
239
|
+
if (el.tagName === 'DIV') {
|
|
240
|
+
newWrapper.className = 'component';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
newWrapper.innerHTML = transformedHtml;
|
|
244
|
+
el.replaceWith(newWrapper);
|
|
245
|
+
debugLog(PHASE_FETCH, src, debug);
|
|
246
|
+
|
|
247
|
+
// MutationObserver handles parsing and hydrating the new content.
|
|
248
|
+
// Branch nodes are registered in the manifest by mountBranch,
|
|
249
|
+
// so the observer can find parents even inside conditional branches.
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// If any scripts had async imports, wait for them before finalizing.
|
|
254
|
+
// Otherwise finalize synchronously (preserves original boot timing).
|
|
255
|
+
if (asyncTasks.length > 0) {
|
|
256
|
+
return Promise.all(asyncTasks).then(finalize);
|
|
218
257
|
}
|
|
258
|
+
finalize();
|
|
219
259
|
})
|
|
220
260
|
.catch((error) => {
|
|
221
261
|
// Clean up pending fetch tracker
|
|
@@ -230,6 +270,34 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
|
230
270
|
if (el.parentNode) {
|
|
231
271
|
el.remove();
|
|
232
272
|
}
|
|
233
|
-
// Don't recursively call - let MutationObserver handle it
|
|
234
273
|
});
|
|
235
274
|
};
|
|
275
|
+
|
|
276
|
+
export const processComponent = (rootElement, onComplete, config = {}) => {
|
|
277
|
+
const debug = !!config?.debug;
|
|
278
|
+
const allComponents = rootElement.querySelectorAll('component[src], div.component[src]');
|
|
279
|
+
|
|
280
|
+
if (allComponents.length === 0) {
|
|
281
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Only process top-level components — skip those nested inside other
|
|
286
|
+
// unprocessed component[src] elements (they're slot content that will
|
|
287
|
+
// be revealed when the parent component finalizes).
|
|
288
|
+
const topLevel = Array.from(allComponents).filter(
|
|
289
|
+
el => !isNestedInUnprocessedComponent(el, rootElement)
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (topLevel.length === 0) {
|
|
293
|
+
if (onComplete) queueMicrotask(() => onComplete());
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Fetch and process all top-level components in parallel.
|
|
298
|
+
// Nested components (inside finalized content) are discovered and
|
|
299
|
+
// processed by the MutationObserver → processComponent chain.
|
|
300
|
+
// onComplete is handled by checkCleanup (which fires when no
|
|
301
|
+
// component[src] elements remain).
|
|
302
|
+
topLevel.forEach(el => processSingle(el, debug));
|
|
303
|
+
};
|
package/runtime/conditionals.js
CHANGED
|
@@ -4,6 +4,50 @@ import hydrate from './hydrate.js';
|
|
|
4
4
|
import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
|
|
5
5
|
import { evalInScope } from './utils.js';
|
|
6
6
|
|
|
7
|
+
// Registry of DOM nodes owned by conditional branches.
|
|
8
|
+
// Maps a DOM node to { nodes: array_ref, index: number } so that
|
|
9
|
+
// processMutations can update the reference when processComponent
|
|
10
|
+
// replaces the node (el.replaceWith). Keeps conditional state in
|
|
11
|
+
// sync with actual DOM without sweeps or special properties.
|
|
12
|
+
export const branchNodeRegistry = new WeakMap();
|
|
13
|
+
|
|
14
|
+
// Nodes that have been processed by mountBranch or renderIteration.
|
|
15
|
+
// processMutations checks this to avoid re-processing already-handled content.
|
|
16
|
+
export const managedNodes = new WeakSet();
|
|
17
|
+
|
|
18
|
+
// Find the dot path of a conditional node in the manifest.
|
|
19
|
+
// Searches for the parent element, then appends the conditional's key.
|
|
20
|
+
const findConditionalPath = (node, manifest) => {
|
|
21
|
+
const parent = node.meta.startComment.parentNode;
|
|
22
|
+
const parentEntry = Object.entries(manifest).find(([_, el]) => el === parent);
|
|
23
|
+
if (!parentEntry) return null;
|
|
24
|
+
|
|
25
|
+
// Find which key this conditional has in the parent's children
|
|
26
|
+
// by matching the startComment reference
|
|
27
|
+
const [parentPath] = parentEntry;
|
|
28
|
+
return `${parentPath}.${node._key}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Register branch tree nodes in the manifest (recursive)
|
|
32
|
+
const addBranchToManifest = (tree, manifest, basePath) => {
|
|
33
|
+
if (tree.element) manifest[basePath] = tree.element;
|
|
34
|
+
if (tree.children) {
|
|
35
|
+
for (const key in tree.children) {
|
|
36
|
+
addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Remove branch tree nodes from the manifest (recursive)
|
|
42
|
+
const removeBranchFromManifest = (tree, manifest, basePath) => {
|
|
43
|
+
delete manifest[basePath];
|
|
44
|
+
if (tree.children) {
|
|
45
|
+
for (const key in tree.children) {
|
|
46
|
+
removeBranchFromManifest(tree.children[key], manifest, `${basePath}.${key}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
7
51
|
// Evaluate conditional expression in state context
|
|
8
52
|
const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
|
|
9
53
|
|
|
@@ -96,12 +140,12 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
96
140
|
|
|
97
141
|
// If branch doesn't exist (no else clause), just unmount current
|
|
98
142
|
if (!branchData) {
|
|
99
|
-
unmountBranch(node);
|
|
143
|
+
unmountBranch(node, manifest);
|
|
100
144
|
return;
|
|
101
145
|
}
|
|
102
146
|
|
|
103
147
|
// Unmount current branch first (if any)
|
|
104
|
-
unmountBranch(node);
|
|
148
|
+
unmountBranch(node, manifest);
|
|
105
149
|
|
|
106
150
|
// Clone the template element
|
|
107
151
|
const templateContent = branchData.element.childNodes;
|
|
@@ -118,8 +162,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
118
162
|
clonedNodes,
|
|
119
163
|
} = initializeBlock(templateContent, scopedState);
|
|
120
164
|
|
|
121
|
-
// Insert cloned nodes into DOM
|
|
122
|
-
clonedNodes.forEach((clonedNode) =>
|
|
165
|
+
// Insert cloned nodes into DOM and register in branch registry
|
|
166
|
+
clonedNodes.forEach((clonedNode, i) => {
|
|
167
|
+
parent.insertBefore(clonedNode, endComment);
|
|
168
|
+
branchNodeRegistry.set(clonedNode, { nodes: clonedNodes, index: i });
|
|
169
|
+
if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Integrate branch tree into the conditional node's children and manifest.
|
|
173
|
+
// This makes branch content visible to the main update loop (hydrate,
|
|
174
|
+
// renderAllConditionals, renderAllIterations) and to MutationObserver
|
|
175
|
+
// (which looks up parents in the manifest).
|
|
176
|
+
if (branchTree?.children) {
|
|
177
|
+
for (const key in branchTree.children) {
|
|
178
|
+
node.children[key] = branchTree.children[key];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const condPath = findConditionalPath(node, manifest);
|
|
182
|
+
if (condPath) {
|
|
183
|
+
for (const key in branchTree.children) {
|
|
184
|
+
addBranchToManifest(branchTree.children[key], manifest, `${condPath}.${key}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
123
188
|
|
|
124
189
|
// Recursively render any nested iterations and conditionals
|
|
125
190
|
if (branchTree) {
|
|
@@ -136,13 +201,26 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
|
|
|
136
201
|
};
|
|
137
202
|
|
|
138
203
|
// Unmount currently active branch
|
|
139
|
-
const unmountBranch = (node) => {
|
|
204
|
+
const unmountBranch = (node, manifest) => {
|
|
140
205
|
const { activeInstance } = node.runtime;
|
|
141
206
|
|
|
142
207
|
if (!activeInstance) return;
|
|
143
208
|
|
|
144
|
-
// Remove
|
|
209
|
+
// Remove branch children from the conditional node's tree and manifest
|
|
210
|
+
if (activeInstance.parsedTree?.children) {
|
|
211
|
+
const condPath = manifest ? findConditionalPath(node, manifest) : null;
|
|
212
|
+
|
|
213
|
+
for (const key in activeInstance.parsedTree.children) {
|
|
214
|
+
delete node.children[key];
|
|
215
|
+
if (condPath) {
|
|
216
|
+
removeBranchFromManifest(activeInstance.parsedTree.children[key], manifest, `${condPath}.${key}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Remove all nodes from DOM and deregister from branch registry
|
|
145
222
|
activeInstance.nodes.forEach((domNode) => {
|
|
223
|
+
branchNodeRegistry.delete(domNode);
|
|
146
224
|
if (domNode.parentNode) {
|
|
147
225
|
domNode.parentNode.removeChild(domNode);
|
|
148
226
|
}
|
|
@@ -173,7 +251,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
173
251
|
mountBranch(node, newBranchData, newState, manifest, parentScope);
|
|
174
252
|
node.runtime.activeBranch = newBranchData;
|
|
175
253
|
} else {
|
|
176
|
-
// Same branch, but state might have changed
|
|
254
|
+
// Same branch, but state might have changed — rehydrate bindings
|
|
255
|
+
// and update nested conditionals/iterations
|
|
177
256
|
const { activeInstance } = node.runtime;
|
|
178
257
|
|
|
179
258
|
if (activeInstance && activeInstance.parsedTree) {
|
|
@@ -182,6 +261,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
|
|
|
182
261
|
|
|
183
262
|
const affectedElements = affected(activeInstance.parsedTree, oldState, scopedState);
|
|
184
263
|
hydrate(affectedElements, scopedState);
|
|
264
|
+
renderAllConditionals(activeInstance.parsedTree, scopedState, manifest, parentScope);
|
|
265
|
+
renderAllIterations(activeInstance.parsedTree, scopedState, manifest, parentScope);
|
|
185
266
|
}
|
|
186
267
|
}
|
|
187
268
|
};
|
package/runtime/constants.js
CHANGED
|
@@ -202,15 +202,17 @@ export const DOM_ELEMENT_PROPERTIES = new Set([
|
|
|
202
202
|
]);
|
|
203
203
|
|
|
204
204
|
// Regex for matching reactive bindings (@[expression])
|
|
205
|
-
// Supports
|
|
206
|
-
|
|
205
|
+
// Supports nested brackets, single-quoted and double-quoted strings inside expressions:
|
|
206
|
+
// @[items[0]], @[obj[key]], @[x.replace('.png', '-mugshot.png')]
|
|
207
|
+
const BINDING_INNER = String.raw`(?:[^\[\]'"]|\[[^\]]*\]|'[^']*'|"[^"]*")+`;
|
|
208
|
+
export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g');
|
|
207
209
|
|
|
208
210
|
// Regex for detecting a pure binding (entire value is just @[expression])
|
|
209
|
-
export const PURE_BINDING_REGEX =
|
|
211
|
+
export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
|
|
210
212
|
|
|
211
213
|
// Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
|
|
212
|
-
// Supports nested paths like category.items
|
|
213
|
-
export const ITERATION_REGEX = /^each\s+([\w
|
|
214
|
+
// Supports nested paths like category.items and bracket notation like teams[0].combatants
|
|
215
|
+
export const ITERATION_REGEX = /^each\s+([\w.\[\]]+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
|
|
214
216
|
|
|
215
217
|
// Regex for detecting start of iteration comment
|
|
216
218
|
export const ITERATION_START_REGEX = /^each\s+/;
|
package/runtime/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import hydrate from './hydrate.js';
|
|
|
5
5
|
import affected from './affected.js';
|
|
6
6
|
import { deepMerge, hash } from './utils.js';
|
|
7
7
|
import { renderAllIterations, setRenderAllConditionals } from './iterate.js';
|
|
8
|
-
import { renderAllConditionals } from './conditionals.js';
|
|
8
|
+
import { renderAllConditionals, branchNodeRegistry, managedNodes } from './conditionals.js';
|
|
9
9
|
import {
|
|
10
10
|
NON_REACTIVE_ELEMENTS,
|
|
11
11
|
PHASE_ATTACH,
|
|
@@ -39,6 +39,9 @@ const shouldProcessNode = (node) => {
|
|
|
39
39
|
// Only process element nodes
|
|
40
40
|
if (node.nodeType !== 1) return false;
|
|
41
41
|
|
|
42
|
+
// Skip nodes already managed by mountBranch or renderIteration
|
|
43
|
+
if (managedNodes.has(node)) return false;
|
|
44
|
+
|
|
42
45
|
// Fast check first: skip nodes without Vibe syntax (cheapest check)
|
|
43
46
|
const html = node.outerHTML;
|
|
44
47
|
if (
|
|
@@ -116,9 +119,15 @@ const processCoreLoop = (node, parsedNode, state, manifest, isNewNode, debug) =>
|
|
|
116
119
|
// 1. Parse - already done before calling core loop (in processMutations)
|
|
117
120
|
|
|
118
121
|
// 2. Hydrate (replace @[...] bindings)
|
|
119
|
-
//
|
|
122
|
+
// For new nodes, use {} so all bindings are found. But filter out iterations
|
|
123
|
+
// and conditionals — those should only go through renderAllIterations/renderAllConditionals
|
|
124
|
+
// (initial render path), not updateIteration/updateConditional (which would diff against
|
|
125
|
+
// stale oldState and produce false adds/removes).
|
|
120
126
|
const oldStateForAffected = isNewNode ? {} : previousState;
|
|
121
|
-
|
|
127
|
+
let affectedElements = affected(parsedNode, oldStateForAffected, state);
|
|
128
|
+
if (isNewNode) {
|
|
129
|
+
affectedElements = affectedElements.filter(a => a.type !== 'iteration' && a.type !== 'conditional');
|
|
130
|
+
}
|
|
122
131
|
if (affectedElements.length > 0) {
|
|
123
132
|
hydratedCount = affectedElements.length;
|
|
124
133
|
hydrate(affectedElements, state, manifest, oldStateForAffected);
|
|
@@ -509,30 +518,45 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
509
518
|
};
|
|
510
519
|
|
|
511
520
|
// Extract plain values from proxy (removes proxy wrappers)
|
|
521
|
+
// Optimized: indexed loops, Object.keys (no prototype walk), inline primitive check
|
|
512
522
|
const extractPlainValue = (obj) => {
|
|
513
523
|
if (obj === null || typeof obj !== 'object') return obj;
|
|
514
|
-
if (Array.isArray(obj))
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
524
|
+
if (Array.isArray(obj)) {
|
|
525
|
+
const len = obj.length;
|
|
526
|
+
const arr = new Array(len);
|
|
527
|
+
for (let i = 0; i < len; i++) {
|
|
528
|
+
const v = obj[i];
|
|
529
|
+
arr[i] = (v !== null && typeof v === 'object') ? extractPlainValue(v) : v;
|
|
519
530
|
}
|
|
531
|
+
return arr;
|
|
532
|
+
}
|
|
533
|
+
const keys = Object.keys(obj);
|
|
534
|
+
const len = keys.length;
|
|
535
|
+
const plain = {};
|
|
536
|
+
for (let i = 0; i < len; i++) {
|
|
537
|
+
const k = keys[i];
|
|
538
|
+
const v = obj[k];
|
|
539
|
+
plain[k] = (v !== null && typeof v === 'object') ? extractPlainValue(v) : v;
|
|
520
540
|
}
|
|
521
541
|
return plain;
|
|
522
542
|
};
|
|
523
543
|
|
|
524
544
|
// Observer callback will be defined below (already declared above before processComponent)
|
|
525
545
|
|
|
526
|
-
const $ = state(s, (
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
546
|
+
const $ = state(s, (changedProps) => {
|
|
547
|
+
// Selective extraction: only extract changed props, preserve references for unchanged.
|
|
548
|
+
// This ensures affected() correctly skips iterations whose arrays didn't change,
|
|
549
|
+
// while still detecting binding changes inside iteration instances.
|
|
550
|
+
const currentState = { ...previousState };
|
|
551
|
+
for (const prop of changedProps) {
|
|
552
|
+
currentState[prop] = extractPlainValue($[prop]);
|
|
553
|
+
}
|
|
530
554
|
|
|
531
555
|
// Find what changed (compare previousState vs currentState)
|
|
532
556
|
const affectedElements = affected(parsedTree, previousState, currentState);
|
|
533
557
|
|
|
534
558
|
if (affectedElements.length > 0) {
|
|
535
|
-
debugLog(PHASE_UPDATE,
|
|
559
|
+
debugLog(PHASE_UPDATE, 'state changed', debug);
|
|
536
560
|
|
|
537
561
|
// Capture pending mutations before disconnecting (takeRecords clears the queue)
|
|
538
562
|
let pendingMutations = [];
|
|
@@ -562,6 +586,18 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
562
586
|
processMutations(pendingMutations);
|
|
563
587
|
}
|
|
564
588
|
}
|
|
589
|
+
|
|
590
|
+
// Conditionals/iterations may have mounted new DOM while observer was disconnected.
|
|
591
|
+
// Scan for unresolved <component src=""> elements that need fetching.
|
|
592
|
+
if (componentProcessingStarted) {
|
|
593
|
+
const componentConfig = {
|
|
594
|
+
...config,
|
|
595
|
+
_forceSync: true,
|
|
596
|
+
_observer: observer,
|
|
597
|
+
_processMutations: processMutations,
|
|
598
|
+
};
|
|
599
|
+
processComponent(rootElement, null, componentConfig);
|
|
600
|
+
}
|
|
565
601
|
}
|
|
566
602
|
|
|
567
603
|
// Store previous state for hooks (currentState is already plain, no need to clone)
|
|
@@ -667,6 +703,20 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
667
703
|
abortComponentFetch(node);
|
|
668
704
|
}
|
|
669
705
|
|
|
706
|
+
// If this node is tracked by a conditional branch (e.g. a <component src>
|
|
707
|
+
// that was replaced by processComponent via el.replaceWith), update the
|
|
708
|
+
// conditional's tracked reference to point to the replacement node.
|
|
709
|
+
const branchRef = branchNodeRegistry.get(node);
|
|
710
|
+
if (branchRef) {
|
|
711
|
+
// Find the replacement: an added node in the same mutation at the same parent
|
|
712
|
+
const replacement = Array.from(addedNodesList).find(n => n.parentNode === target);
|
|
713
|
+
if (replacement) {
|
|
714
|
+
branchRef.nodes[branchRef.index] = replacement;
|
|
715
|
+
branchNodeRegistry.set(replacement, branchRef);
|
|
716
|
+
}
|
|
717
|
+
branchNodeRegistry.delete(node);
|
|
718
|
+
}
|
|
719
|
+
|
|
670
720
|
const entry = Object.entries(manifest).find(([_, element]) => element === node);
|
|
671
721
|
|
|
672
722
|
// Skip nodes that aren't tracked (e.g., iteration-generated nodes or nodes outside reactive scope)
|
|
@@ -907,7 +957,8 @@ const main = (s, config = {}, stringSelector = '') => {
|
|
|
907
957
|
|
|
908
958
|
// Check for new <component> elements after DOM mutations
|
|
909
959
|
// Process component elements if we've started (initial call happened)
|
|
910
|
-
|
|
960
|
+
// Note: Also process after cleanup — conditionals may reveal new components
|
|
961
|
+
if (componentProcessingStarted) {
|
|
911
962
|
const componentConfig = {
|
|
912
963
|
...config,
|
|
913
964
|
_forceSync: true,
|