@ape-egg/vibe 1.7.2 → 1.9.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.
@@ -1,5 +1,5 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_FETCH, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
2
+ import { PHASE_FETCH, DEHYDRATE_CLASS_OR_ATTR, BINDING_REGEX } from './constants.js';
3
3
  import { evalInScope } from './utils.js';
4
4
 
5
5
  // Deterministic component counter
@@ -16,6 +16,42 @@ export const generateComponentId = () => {
16
16
  // Helper to escape regex special characters
17
17
  const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18
18
 
19
+ // Kept as no-ops for API compatibility — path-based tracking was replaced by
20
+ // DOM-scan cleanup in releaseOrphanedComponentState. Manifest paths don't
21
+ // align 1:1 with component ownership across conditional remounts, so scanning
22
+ // the DOM subtree about to be removed is simpler and waterproof.
23
+ export const trackComponentOwnership = () => {};
24
+
25
+ // Given an iterable of DOM nodes that are about to be (or have just been)
26
+ // removed, find every `data-vibe-component-id` inside them and, for each id
27
+ // whose DOM is fully gone from the live document, evict its state.
28
+ //
29
+ // Call this AFTER the nodes have been detached from the document so the
30
+ // `document.querySelector` check sees the post-removal state.
31
+ export const releaseOrphanedComponentState = (collectedIds) => {
32
+ if (!collectedIds || collectedIds.size === 0) return;
33
+ for (const id of collectedIds) {
34
+ if (document.querySelector(`[data-vibe-component-id="${id}"]`)) continue;
35
+ delete window.__vibeComponents?.[id];
36
+ // CLEANUP OF CURRENT STATE
37
+ delete window.$[id];
38
+ }
39
+ };
40
+
41
+ // Walk a node subtree (element or node list) and collect all
42
+ // `data-vibe-component-id` values found on the node and its descendants.
43
+ export const collectComponentIds = (node, into = new Set()) => {
44
+ if (!node) return into;
45
+ if (node.nodeType === 1) {
46
+ const id = node.getAttribute('data-vibe-component-id');
47
+ if (id) into.add(id);
48
+ node.querySelectorAll?.('[data-vibe-component-id]').forEach((el) => {
49
+ into.add(el.getAttribute('data-vibe-component-id'));
50
+ });
51
+ }
52
+ return into;
53
+ };
54
+
19
55
  // Track pending fetches to cancel them if element is removed
20
56
  const pendingFetches = new WeakMap(); // element → AbortController
21
57
 
@@ -28,36 +64,31 @@ export const abortComponentFetch = (element) => {
28
64
  }
29
65
  };
30
66
 
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;
67
+ // Check if an element is nested inside another unprocessed component[src]
68
+ const isNestedInUnprocessedComponent = (el, rootElement) => {
69
+ let parent = el.parentElement;
70
+ while (parent && parent !== rootElement) {
71
+ if (
72
+ (parent.tagName === 'COMPONENT' || parent.classList?.contains('component')) &&
73
+ parent.hasAttribute('src')
74
+ ) {
75
+ return true;
76
+ }
77
+ parent = parent.parentElement;
43
78
  }
79
+ return false;
80
+ };
44
81
 
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)
82
+ // Process a single component element: fetch HTML, execute scripts, replace DOM
83
+ const processSingle = (el, debug) => {
84
+ // Skip dehydrated components
49
85
  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;
86
+ return Promise.resolve();
59
87
  }
60
88
 
89
+ // Skip if fetch already in flight for this element
90
+ if (pendingFetches.has(el)) return Promise.resolve();
91
+
61
92
  const src = el.getAttribute('src');
62
93
 
63
94
  // Use pre-hydration slot content if available (saved by index.js before hydration ran),
@@ -75,7 +106,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
75
106
  const controller = new AbortController();
76
107
  pendingFetches.set(el, controller);
77
108
 
78
- fetch(src, { signal: controller.signal })
109
+ return fetch(src, { signal: controller.signal })
79
110
  .then((r) => r.text())
80
111
  .then((html) => {
81
112
  // Parse HTML in temporary container to process component scripts
@@ -84,68 +115,94 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
84
115
 
85
116
  // Process any <script type="module"> elements
86
117
  const moduleScripts = temp.querySelectorAll('script[type="module"]');
87
- moduleScripts.forEach((script) => {
88
- let scriptContent = script.textContent?.trim() || '';
89
- if (!scriptContent) return;
90
118
 
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, '');
119
+ // Process each script collect async tasks if any have imports
120
+ const asyncTasks = [];
121
+
122
+ for (const script of moduleScripts) {
123
+ let scriptContent = script.textContent?.trim() || '';
124
+ if (!scriptContent) continue;
125
+
126
+ // Strip `import component from '...'` — Vibe injects the contextual
127
+ // component() function as a parameter (it needs access to the temp DOM)
128
+ scriptContent = scriptContent.replace(
129
+ /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
130
+ ''
131
+ );
132
+
133
+ // Check for remaining imports that need rewriting
134
+ const hasImports = /import\s/.test(scriptContent);
135
+
136
+ if (hasImports) {
137
+ // Rewrite remaining imports to dynamic await import()
138
+ // Order matters: default → named → namespace → side-effect (most specific first)
139
+ scriptContent = scriptContent.replace(
140
+ /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
141
+ 'const $1 = (await import($2)).default;'
142
+ );
143
+ scriptContent = scriptContent.replace(
144
+ /import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
145
+ 'const {$1} = await import($2);'
146
+ );
147
+ scriptContent = scriptContent.replace(
148
+ /import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
149
+ 'const $1 = await import($2);'
150
+ );
151
+ scriptContent = scriptContent.replace(
152
+ /import\s+(['"][^'"]+['"])\s*;?/g,
153
+ 'await import($1);'
154
+ );
155
+ }
94
156
 
95
- // Generate component ID for this instance
96
- const componentId = generateComponentId();
157
+ // Reuse component ID from HMR if available, otherwise generate new
158
+ const reuseIds = el._vibeReuseComponentIds;
159
+ const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
97
160
 
98
- // Provide a component() function that registers state for this component
161
+ // Provide a component() function that registers state for this component.
162
+ // Sibling tagging is handled below (before script.remove()) so it works
163
+ // for both sync and async scripts.
99
164
  const componentFn = (state) => {
100
- // Register component state in both places:
101
- // 1. __vibeComponents registry (for pre-boot components)
102
- if (!window.__vibeComponents) {
103
- window.__vibeComponents = {};
104
- }
165
+ if (!window.__vibeComponents) window.__vibeComponents = {};
105
166
  window.__vibeComponents[componentId] = state;
106
-
107
- // 2. Directly in window.$ (the reactive proxy) for post-boot components
108
- if (window.$) {
109
- window.$[componentId] = state;
110
- }
111
-
112
- // Tag all siblings (everything after the script in this component)
113
- let sibling = script.nextElementSibling;
114
- while (sibling) {
115
- // Stop if we hit another module script
116
- if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
117
- break;
118
- }
119
- sibling.setAttribute('data-vibe-component-id', componentId);
120
- sibling = sibling.nextElementSibling;
121
- }
167
+ if (window.$) window.$[componentId] = state;
122
168
  };
123
169
 
124
170
  // Execute script with component() function in scope
125
- // Use Function constructor to provide 'component' as a parameter
126
171
  try {
127
- const executeFn = new Function('component', scriptContent);
128
- executeFn(componentFn);
172
+ if (hasImports) {
173
+ // Async execution for scripts with imports
174
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
175
+ asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
176
+ } else {
177
+ // Synchronous execution for scripts without imports (preserves boot timing)
178
+ const executeFn = new Function('component', scriptContent);
179
+ executeFn(componentFn);
180
+ }
129
181
  } catch (e) {
130
182
  console.warn('[vibe] Failed to execute component script:', e);
131
183
  }
132
184
 
133
- // Rewrite this.property to componentId.property in siblings
134
- // This allows the runtime to resolve component-scoped bindings
185
+ // Rewrite this.property to componentId.property in siblings.
186
+ // Handles both reads (@[this.x]) and writes ($.this.x in event handlers)
187
+ // so component() state is fully accessible from the component's own template.
135
188
  const rewriteThisBindings = (element) => {
136
- const thisRegex = /@\[this\.(\w+)\]/g;
189
+ const bindingRegex = /@\[this\.(\w+)\]/g;
190
+ const writeRegex = /\$\.this\.(\w+)/g;
137
191
 
138
192
  // Rewrite in text nodes
139
193
  Array.from(element.childNodes).forEach((node) => {
140
194
  if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
141
- node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
195
+ node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
142
196
  }
143
197
  });
144
198
 
145
- // Rewrite in attributes
199
+ // Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
146
200
  Array.from(element.attributes || []).forEach((attr) => {
147
201
  if (attr.value.includes('@[this.')) {
148
- attr.value = attr.value.replace(thisRegex, `@[${componentId}.$1]`);
202
+ attr.value = attr.value.replace(bindingRegex, `@[${componentId}.$1]`);
203
+ }
204
+ if (attr.value.includes('$.this.')) {
205
+ attr.value = attr.value.replace(writeRegex, `$.${componentId}.$1`);
149
206
  }
150
207
  });
151
208
 
@@ -155,67 +212,159 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
155
212
  });
156
213
  };
157
214
 
215
+ // Tag siblings with componentId and rewrite this. bindings.
216
+ // This runs BEFORE script.remove() so nextElementSibling is valid.
217
+ // For async scripts, componentFn runs later (after removal) and can't
218
+ // find siblings — so we tag here instead.
158
219
  let sibling = script.nextElementSibling;
159
220
  while (sibling) {
160
221
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
161
222
  break;
162
223
  }
224
+ sibling.setAttribute('data-vibe-component-id', componentId);
163
225
  rewriteThisBindings(sibling);
164
226
  sibling = sibling.nextElementSibling;
165
227
  }
166
228
 
167
229
  // Remove script from temp (we executed it manually)
168
230
  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);
231
+ }
232
+
233
+ // Finalize: props, slots, DOM replacement
234
+ const finalize = () => {
235
+ // Get transformed HTML from temp container (scripts removed)
236
+ let transformedHtml = temp.innerHTML;
237
+
238
+ // Replace props — both exact @[propName] bindings and identifiers
239
+ // appearing inside larger expressions like @[Math.floor(propName / 100)]
240
+ // or conditional/iteration comment expressions like <!-- if propName -->.
241
+ // Matches any <!-- if ... -->, <!-- else if ... -->, or <!-- each ... -->
242
+ // so prop identifiers resolve there the same way they do inside @[...].
243
+ const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
244
+ // Event handler attributes (onclick, oninput, onchange, …). Prop
245
+ // identifiers inside these are JS expressions that read/write state —
246
+ // substituting them gives components natural two-way binding:
247
+ // child writes `$.value = x`, parent passed `value="@[email]"`,
248
+ // substitution turns it into `$.email = x`.
249
+ const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
250
+
251
+ Object.entries(props).forEach(([propName, propValue]) => {
252
+ const bindingMatch = propValue.match(/^@\[(.+)\]$/);
253
+ const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
254
+ // Word-boundary identifier match for substitution inside expressions.
255
+ // The lookbehind also excludes `.` so property accesses like `_c0.email`
256
+ // aren't double-substituted when the prop name is `email` — only
257
+ // standalone identifiers match, not property tails.
258
+ const idRegex = new RegExp(
259
+ `(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
260
+ 'g'
261
+ );
262
+
263
+ const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
264
+
265
+ if (bindingMatch) {
266
+ // Reactive prop: @[propName] → @[path], identifiers inside expressions → (path)
267
+ const path = bindingMatch[1];
268
+ transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
269
+ transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
270
+ const rewritten = substituteInExpr(expr, `(${path})`);
271
+ return rewritten === expr ? match : `@[${rewritten}]`;
272
+ });
273
+ transformedHtml = transformedHtml.replace(
274
+ DIRECTIVE_COMMENT_REGEX,
275
+ (match, kw, expr) => {
276
+ const replacement = kw === 'each' ? path : `(${path})`;
277
+ const rewritten = substituteInExpr(expr, replacement);
278
+ return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
279
+ }
280
+ );
281
+ // Event handlers: rewrite `$.propName` → `$.path` for two-way
282
+ // binding. Only targets state access (`$.xxx`) so DOM properties
283
+ // like `this.value` stay untouched.
284
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
285
+ transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
286
+ const rewritten = body.replace(stateRegex, `$.${path}`);
287
+ return rewritten === body ? match : `on${evName}="${rewritten}"`;
288
+ });
289
+ } else {
290
+ // Static prop: @[propName] → literal value (raw),
291
+ // identifiers inside expressions → JS literal.
292
+ transformedHtml = transformedHtml.replace(exactPattern, propValue);
293
+ const isNumeric =
294
+ typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
295
+ const isBooleanAttr = propValue === '';
296
+ const literal = isBooleanAttr
297
+ ? 'true'
298
+ : typeof propValue === 'string' && !isNumeric
299
+ ? JSON.stringify(propValue)
300
+ : String(propValue);
301
+ transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
302
+ const rewritten = substituteInExpr(expr, literal);
303
+ return rewritten === expr ? match : `@[${rewritten}]`;
304
+ });
305
+ transformedHtml = transformedHtml.replace(
306
+ DIRECTIVE_COMMENT_REGEX,
307
+ (match, kw, expr) => {
308
+ const rewritten = substituteInExpr(expr, literal);
309
+ return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
310
+ }
311
+ );
312
+ // Event handlers: rewrite `$.propName` → `$.literal` for static props
313
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
314
+ transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
315
+ const rewritten = body.replace(stateRegex, `$.${literal}`);
316
+ return rewritten === body ? match : `on${evName}="${rewritten}"`;
317
+ });
318
+ }
319
+ });
320
+
321
+ // Replace <slot> with children wrapped in <slot> boundary.
322
+ // Allow attributes on the slot (e.g. data-vibe-component-id added by
323
+ // sibling tagging when a <slot> happens to be a direct sibling of the
324
+ // component's <script>).
325
+ if (children) {
326
+ transformedHtml = transformedHtml.replace(
327
+ /<slot(\s[^>]*)?>\s*<\/slot>/g,
328
+ (_, attrs) => `<slot${attrs || ''}>${children}</slot>`
329
+ );
330
+ transformedHtml = transformedHtml.replace(
331
+ /<slot(\s[^>]*)?\/>/g,
332
+ (_, attrs) => `<slot${attrs || ''}>${children}</slot>`
333
+ );
187
334
  }
188
- });
189
335
 
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
- }
336
+ // Clean up pending fetch tracker
337
+ pendingFetches.delete(el);
195
338
 
196
- // Clean up pending fetch tracker
197
- pendingFetches.delete(el);
339
+ // Replace with clean component wrapper (no src, no props)
340
+ // Check if element still has a parent (might have been removed during fetch)
341
+ if (el.parentNode) {
342
+ // Create clean wrapper element (preserve tag type: component or div.component)
343
+ const newWrapper =
344
+ el.tagName === 'DIV'
345
+ ? document.createElement('div')
346
+ : document.createElement('component');
198
347
 
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';
210
- }
348
+ if (el.tagName === 'DIV') {
349
+ newWrapper.className = 'component';
350
+ }
211
351
 
212
- newWrapper.innerHTML = transformedHtml;
213
- el.replaceWith(newWrapper);
214
- debugLog(PHASE_FETCH, src, debug);
352
+ newWrapper.innerHTML = transformedHtml;
353
+ el.replaceWith(newWrapper);
354
+ debugLog(PHASE_FETCH, src, debug);
215
355
 
216
- // Let MutationObserver handle the mutation naturally
217
- // It will call processMutations, which will call processComponent for the next component
356
+ // MutationObserver handles parsing and hydrating the new content.
357
+ // Branch nodes are registered in the manifest by mountBranch,
358
+ // so the observer can find parents even inside conditional branches.
359
+ }
360
+ };
361
+
362
+ // If any scripts had async imports, wait for them before finalizing.
363
+ // Otherwise finalize synchronously (preserves original boot timing).
364
+ if (asyncTasks.length > 0) {
365
+ return Promise.all(asyncTasks).then(finalize);
218
366
  }
367
+ finalize();
219
368
  })
220
369
  .catch((error) => {
221
370
  // Clean up pending fetch tracker
@@ -230,6 +379,34 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
230
379
  if (el.parentNode) {
231
380
  el.remove();
232
381
  }
233
- // Don't recursively call - let MutationObserver handle it
234
382
  });
235
383
  };
384
+
385
+ export const processComponent = (rootElement, onComplete, config = {}) => {
386
+ const debug = !!config?.debug;
387
+ const allComponents = rootElement.querySelectorAll('component[src], div.component[src]');
388
+
389
+ if (allComponents.length === 0) {
390
+ if (onComplete) queueMicrotask(() => onComplete());
391
+ return;
392
+ }
393
+
394
+ // Only process top-level components — skip those nested inside other
395
+ // unprocessed component[src] elements (they're slot content that will
396
+ // be revealed when the parent component finalizes).
397
+ const topLevel = Array.from(allComponents).filter(
398
+ (el) => !isNestedInUnprocessedComponent(el, rootElement)
399
+ );
400
+
401
+ if (topLevel.length === 0) {
402
+ if (onComplete) queueMicrotask(() => onComplete());
403
+ return;
404
+ }
405
+
406
+ // Fetch and process all top-level components in parallel.
407
+ // Nested components (inside finalized content) are discovered and
408
+ // processed by the MutationObserver → processComponent chain.
409
+ // onComplete is handled by checkCleanup (which fires when no
410
+ // component[src] elements remain).
411
+ topLevel.forEach((el) => processSingle(el, debug));
412
+ };
@@ -3,6 +3,53 @@ import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
4
  import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
5
5
  import { evalInScope } from './utils.js';
6
+ import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
7
+
8
+ // Registry of DOM nodes owned by conditional branches.
9
+ // Maps a DOM node to { nodes: array_ref, index: number } so that
10
+ // processMutations can update the reference when processComponent
11
+ // replaces the node (el.replaceWith). Keeps conditional state in
12
+ // sync with actual DOM without sweeps or special properties.
13
+ export const branchNodeRegistry = new WeakMap();
14
+
15
+ // Nodes that have been processed by mountBranch or renderIteration.
16
+ // processMutations checks this to avoid re-processing already-handled content.
17
+ export const managedNodes = new WeakSet();
18
+
19
+ // Find the dot path of a conditional node in the manifest.
20
+ // Searches for the parent element, then appends the conditional's key.
21
+ const findConditionalPath = (node, manifest) => {
22
+ const parent = node.meta.startComment.parentNode;
23
+ const parentEntry = Object.entries(manifest).find(([_, el]) => el === parent);
24
+ if (!parentEntry) return null;
25
+
26
+ // Find which key this conditional has in the parent's children
27
+ // by matching the startComment reference
28
+ const [parentPath] = parentEntry;
29
+ return `${parentPath}.${node._key}`;
30
+ };
31
+
32
+ // Register branch tree nodes in the manifest (recursive)
33
+ const addBranchToManifest = (tree, manifest, basePath) => {
34
+ if (tree.element) {
35
+ manifest[basePath] = tree.element;
36
+ }
37
+ if (tree.children) {
38
+ for (const key in tree.children) {
39
+ addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
40
+ }
41
+ }
42
+ };
43
+
44
+ // Remove branch tree nodes from the manifest (recursive)
45
+ const removeBranchFromManifest = (tree, manifest, basePath) => {
46
+ delete manifest[basePath];
47
+ if (tree.children) {
48
+ for (const key in tree.children) {
49
+ removeBranchFromManifest(tree.children[key], manifest, `${basePath}.${key}`);
50
+ }
51
+ }
52
+ };
6
53
 
7
54
  // Evaluate conditional expression in state context
8
55
  const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
@@ -96,12 +143,12 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
96
143
 
97
144
  // If branch doesn't exist (no else clause), just unmount current
98
145
  if (!branchData) {
99
- unmountBranch(node);
146
+ unmountBranch(node, manifest);
100
147
  return;
101
148
  }
102
149
 
103
150
  // Unmount current branch first (if any)
104
- unmountBranch(node);
151
+ unmountBranch(node, manifest);
105
152
 
106
153
  // Clone the template element
107
154
  const templateContent = branchData.element.childNodes;
@@ -118,8 +165,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
118
165
  clonedNodes,
119
166
  } = initializeBlock(templateContent, scopedState);
120
167
 
121
- // Insert cloned nodes into DOM
122
- clonedNodes.forEach((clonedNode) => parent.insertBefore(clonedNode, endComment));
168
+ // Insert cloned nodes into DOM and register in branch registry
169
+ clonedNodes.forEach((clonedNode, i) => {
170
+ parent.insertBefore(clonedNode, endComment);
171
+ branchNodeRegistry.set(clonedNode, { nodes: clonedNodes, index: i });
172
+ if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
173
+ });
174
+
175
+ // Integrate branch tree into the conditional node's children and manifest.
176
+ // This makes branch content visible to the main update loop (hydrate,
177
+ // renderAllConditionals, renderAllIterations) and to MutationObserver
178
+ // (which looks up parents in the manifest).
179
+ if (branchTree?.children) {
180
+ for (const key in branchTree.children) {
181
+ node.children[key] = branchTree.children[key];
182
+ }
183
+
184
+ const condPath = findConditionalPath(node, manifest);
185
+ if (condPath) {
186
+ for (const key in branchTree.children) {
187
+ addBranchToManifest(branchTree.children[key], manifest, `${condPath}.${key}`);
188
+ }
189
+ }
190
+ }
123
191
 
124
192
  // Recursively render any nested iterations and conditionals
125
193
  if (branchTree) {
@@ -136,18 +204,39 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
136
204
  };
137
205
 
138
206
  // Unmount currently active branch
139
- const unmountBranch = (node) => {
207
+ const unmountBranch = (node, manifest) => {
140
208
  const { activeInstance } = node.runtime;
141
209
 
142
210
  if (!activeInstance) return;
143
211
 
144
- // Remove all nodes from DOM
212
+ // Remove branch children from the conditional node's tree and manifest
213
+ if (activeInstance.parsedTree?.children) {
214
+ const condPath = manifest ? findConditionalPath(node, manifest) : null;
215
+
216
+ for (const key in activeInstance.parsedTree.children) {
217
+ delete node.children[key];
218
+ if (condPath) {
219
+ removeBranchFromManifest(activeInstance.parsedTree.children[key], manifest, `${condPath}.${key}`);
220
+ }
221
+ }
222
+ }
223
+
224
+ // Collect componentIds from the subtree BEFORE detaching so we can check
225
+ // after removal whether any live DOM still holds them.
226
+ const ids = new Set();
227
+ activeInstance.nodes.forEach((domNode) => collectComponentIds(domNode, ids));
228
+
229
+ // Remove all nodes from DOM and deregister from branch registry
145
230
  activeInstance.nodes.forEach((domNode) => {
231
+ branchNodeRegistry.delete(domNode);
146
232
  if (domNode.parentNode) {
147
233
  domNode.parentNode.removeChild(domNode);
148
234
  }
149
235
  });
150
236
 
237
+ // CLEANUP OF CURRENT STATE
238
+ releaseOrphanedComponentState(ids);
239
+
151
240
  // Clear active instance
152
241
  node.runtime.activeInstance = null;
153
242
  };
@@ -173,7 +262,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
173
262
  mountBranch(node, newBranchData, newState, manifest, parentScope);
174
263
  node.runtime.activeBranch = newBranchData;
175
264
  } else {
176
- // Same branch, but state might have changed - rehydrate
265
+ // Same branch, but state might have changed rehydrate bindings
266
+ // and update nested conditionals/iterations
177
267
  const { activeInstance } = node.runtime;
178
268
 
179
269
  if (activeInstance && activeInstance.parsedTree) {
@@ -182,6 +272,8 @@ export const updateConditional = (node, newState, oldState, manifest, parentScop
182
272
 
183
273
  const affectedElements = affected(activeInstance.parsedTree, oldState, scopedState);
184
274
  hydrate(affectedElements, scopedState);
275
+ renderAllConditionals(activeInstance.parsedTree, scopedState, manifest, parentScope);
276
+ renderAllIterations(activeInstance.parsedTree, scopedState, manifest, parentScope);
185
277
  }
186
278
  }
187
279
  };