@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.
@@ -20,7 +20,7 @@ markup5ever = "0.12"
20
20
  thiserror = "1.0"
21
21
  regex = "1.10"
22
22
  colored = "2.1"
23
- reqwest = { version = "0.11", features = ["blocking", "rustls-tls"], default-features = false }
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
- // Fetch from URL
1426
- match reqwest::blocking::get(url) {
1427
- Ok(response) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/vibe",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Runtime-first reactivity with optional compiler",
6
6
  "main": "index.js",
@@ -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.)
@@ -28,36 +28,31 @@ export const abortComponentFetch = (element) => {
28
28
  }
29
29
  };
30
30
 
31
- export const processComponent = (rootElement, onComplete, config = {}) => {
32
- const debug = !!config?.debug;
33
- // Only process component elements with src attribute (fetched components)
34
- // Supports: <component src="..."> and <div class="component" src="...">
35
- // Ignores: <component> and <div class="component"> (inline component wrappers)
36
- const componentElements = rootElement.querySelectorAll('component[src], div.component[src]');
37
-
38
- if (componentElements.length === 0) {
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());
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
- // Process just the first element - MutationObserver will trigger next call
46
- const el = componentElements[0];
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
- // Skip this component and continue to next
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
- // Strip import statements (we provide component() manually)
92
- // Remove lines like: import component from '...';
93
- scriptContent = scriptContent.replace(/import\s+\w+\s+from\s+['"][^'"]+['"];?\s*/g, '');
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
- const executeFn = new Function('component', scriptContent);
128
- executeFn(componentFn);
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
- // Clean up pending fetch tracker
197
- pendingFetches.delete(el);
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
- // Replace with clean component wrapper (no src, no props)
200
- // Check if element still has a parent (might have been removed during fetch)
201
- if (el.parentNode) {
202
- // Create clean wrapper element (preserve tag type: component or div.component)
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
- newWrapper.innerHTML = transformedHtml;
213
- el.replaceWith(newWrapper);
214
- debugLog(PHASE_FETCH, src, debug);
227
+ // Clean up pending fetch tracker
228
+ pendingFetches.delete(el);
215
229
 
216
- // Let MutationObserver handle the mutation naturally
217
- // It will call processMutations, which will call processComponent for the next component
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
+ };
@@ -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) => parent.insertBefore(clonedNode, endComment));
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 all nodes from DOM
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 - rehydrate
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
  };
@@ -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 one level of nested brackets: @[items[0]] or @[obj[key]]
206
- export const BINDING_REGEX = /\@\[((?:[^\[\]]|\[[^\]]*\])+)\]/g;
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.]+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
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
- // Use empty object as "previous state" for new nodes so all bindings are affected
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
- const affectedElements = affected(parsedNode, oldStateForAffected, state);
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)) return obj.map(extractPlainValue);
515
- const plain = {};
516
- for (const key in obj) {
517
- if (obj.hasOwnProperty(key)) {
518
- plain[key] = extractPlainValue(obj[key]);
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, (newState, oldState) => {
527
- // Extract current state (after mutation)
528
- const currentState = extractPlainValue($);
529
- const changedProp = Object.keys(newState)[0];
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, `state changed (${changedProp})`, debug);
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
- if (componentProcessingStarted && !cleanupExecuted) {
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,