@ape-egg/vibe 1.8.0 → 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
 
@@ -89,7 +125,10 @@ const processSingle = (el, debug) => {
89
125
 
90
126
  // Strip `import component from '...'` — Vibe injects the contextual
91
127
  // component() function as a parameter (it needs access to the temp DOM)
92
- scriptContent = scriptContent.replace(/import\s+component\s+from\s+['"][^'"]+['"];?\s*/g, '');
128
+ scriptContent = scriptContent.replace(
129
+ /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
130
+ ''
131
+ );
93
132
 
94
133
  // Check for remaining imports that need rewriting
95
134
  const hasImports = /import\s/.test(scriptContent);
@@ -115,40 +154,24 @@ const processSingle = (el, debug) => {
115
154
  );
116
155
  }
117
156
 
118
- // Generate component ID for this instance
119
- 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();
120
160
 
121
- // 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.
122
164
  const componentFn = (state) => {
123
- // Register component state in both places:
124
- // 1. __vibeComponents registry (for pre-boot components)
125
- if (!window.__vibeComponents) {
126
- window.__vibeComponents = {};
127
- }
165
+ if (!window.__vibeComponents) window.__vibeComponents = {};
128
166
  window.__vibeComponents[componentId] = state;
129
-
130
- // 2. Directly in window.$ (the reactive proxy) for post-boot components
131
- if (window.$) {
132
- window.$[componentId] = state;
133
- }
134
-
135
- // Tag all siblings (everything after the script in this component)
136
- let sibling = script.nextElementSibling;
137
- while (sibling) {
138
- // Stop if we hit another module script
139
- if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
140
- break;
141
- }
142
- sibling.setAttribute('data-vibe-component-id', componentId);
143
- sibling = sibling.nextElementSibling;
144
- }
167
+ if (window.$) window.$[componentId] = state;
145
168
  };
146
169
 
147
170
  // Execute script with component() function in scope
148
171
  try {
149
172
  if (hasImports) {
150
173
  // Async execution for scripts with imports
151
- const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
174
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
152
175
  asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
153
176
  } else {
154
177
  // Synchronous execution for scripts without imports (preserves boot timing)
@@ -159,22 +182,27 @@ const processSingle = (el, debug) => {
159
182
  console.warn('[vibe] Failed to execute component script:', e);
160
183
  }
161
184
 
162
- // Rewrite this.property to componentId.property in siblings
163
- // 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.
164
188
  const rewriteThisBindings = (element) => {
165
- const thisRegex = /@\[this\.(\w+)\]/g;
189
+ const bindingRegex = /@\[this\.(\w+)\]/g;
190
+ const writeRegex = /\$\.this\.(\w+)/g;
166
191
 
167
192
  // Rewrite in text nodes
168
193
  Array.from(element.childNodes).forEach((node) => {
169
194
  if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
170
- node.textContent = node.textContent.replace(thisRegex, `@[${componentId}.$1]`);
195
+ node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
171
196
  }
172
197
  });
173
198
 
174
- // Rewrite in attributes
199
+ // Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
175
200
  Array.from(element.attributes || []).forEach((attr) => {
176
201
  if (attr.value.includes('@[this.')) {
177
- 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`);
178
206
  }
179
207
  });
180
208
 
@@ -184,11 +212,16 @@ const processSingle = (el, debug) => {
184
212
  });
185
213
  };
186
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.
187
219
  let sibling = script.nextElementSibling;
188
220
  while (sibling) {
189
221
  if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
190
222
  break;
191
223
  }
224
+ sibling.setAttribute('data-vibe-component-id', componentId);
192
225
  rewriteThisBindings(sibling);
193
226
  sibling = sibling.nextElementSibling;
194
227
  }
@@ -202,26 +235,102 @@ const processSingle = (el, debug) => {
202
235
  // Get transformed HTML from temp container (scripts removed)
203
236
  let transformedHtml = temp.innerHTML;
204
237
 
205
- // Replace props
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
+
206
251
  Object.entries(props).forEach(([propName, propValue]) => {
207
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);
208
264
 
209
265
  if (bindingMatch) {
210
- // Reactive prop: replace @[propName] with @[path]
266
+ // Reactive prop: @[propName] @[path], identifiers inside expressions → (path)
211
267
  const path = bindingMatch[1];
212
- const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
213
- transformedHtml = transformedHtml.replace(propPattern, `@[${path}]`);
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
+ });
214
289
  } else {
215
- // Static prop: replace @[propName] with literal value
216
- const propPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
217
- transformedHtml = transformedHtml.replace(propPattern, propValue);
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
+ });
218
318
  }
219
319
  });
220
320
 
221
- // Replace <slot></slot> with children
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>).
222
325
  if (children) {
223
- transformedHtml = transformedHtml.replace(/<slot><\/slot>/g, children);
224
- transformedHtml = transformedHtml.replace(/<slot\s*\/>/g, 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
+ );
225
334
  }
226
335
 
227
336
  // Clean up pending fetch tracker
@@ -286,7 +395,7 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
286
395
  // unprocessed component[src] elements (they're slot content that will
287
396
  // be revealed when the parent component finalizes).
288
397
  const topLevel = Array.from(allComponents).filter(
289
- el => !isNestedInUnprocessedComponent(el, rootElement)
398
+ (el) => !isNestedInUnprocessedComponent(el, rootElement)
290
399
  );
291
400
 
292
401
  if (topLevel.length === 0) {
@@ -299,5 +408,5 @@ export const processComponent = (rootElement, onComplete, config = {}) => {
299
408
  // processed by the MutationObserver → processComponent chain.
300
409
  // onComplete is handled by checkCleanup (which fires when no
301
410
  // component[src] elements remain).
302
- topLevel.forEach(el => processSingle(el, debug));
411
+ topLevel.forEach((el) => processSingle(el, debug));
303
412
  };
@@ -3,6 +3,7 @@ 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';
6
7
 
7
8
  // Registry of DOM nodes owned by conditional branches.
8
9
  // Maps a DOM node to { nodes: array_ref, index: number } so that
@@ -30,7 +31,9 @@ const findConditionalPath = (node, manifest) => {
30
31
 
31
32
  // Register branch tree nodes in the manifest (recursive)
32
33
  const addBranchToManifest = (tree, manifest, basePath) => {
33
- if (tree.element) manifest[basePath] = tree.element;
34
+ if (tree.element) {
35
+ manifest[basePath] = tree.element;
36
+ }
34
37
  if (tree.children) {
35
38
  for (const key in tree.children) {
36
39
  addBranchToManifest(tree.children[key], manifest, `${basePath}.${key}`);
@@ -218,6 +221,11 @@ const unmountBranch = (node, manifest) => {
218
221
  }
219
222
  }
220
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
+
221
229
  // Remove all nodes from DOM and deregister from branch registry
222
230
  activeInstance.nodes.forEach((domNode) => {
223
231
  branchNodeRegistry.delete(domNode);
@@ -226,6 +234,9 @@ const unmountBranch = (node, manifest) => {
226
234
  }
227
235
  });
228
236
 
237
+ // CLEANUP OF CURRENT STATE
238
+ releaseOrphanedComponentState(ids);
239
+
229
240
  // Clear active instance
230
241
  node.runtime.activeInstance = null;
231
242
  };
@@ -210,9 +210,11 @@ export const BINDING_REGEX = new RegExp(String.raw`\@\[(${BINDING_INNER})\]`, 'g
210
210
  // Regex for detecting a pure binding (entire value is just @[expression])
211
211
  export const PURE_BINDING_REGEX = new RegExp(String.raw`^\@\[(${BINDING_INNER})\]$`);
212
212
 
213
- // Regex for parsing iteration comment syntax (<!-- each items as item, index -->)
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*$/;
213
+ // Regex for parsing iteration comment syntax (<!-- each expression as item, index -->)
214
+ // The array expression can be any JS: a state path (items), a window global
215
+ // (window.fights), a method call (items.filter(x => x.active)), or an inline
216
+ // array literal (['a', 'b']). Parsed via evalInScope at render time.
217
+ export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*,\s*(\w+))?\s*$/;
216
218
 
217
219
  // Regex for detecting start of iteration comment
218
220
  export const ITERATION_START_REGEX = /^each\s+/;
package/runtime/index.js CHANGED
@@ -21,9 +21,10 @@ import {
21
21
  PHASE_READY,
22
22
  DEHYDRATE_CLASS_OR_ATTR,
23
23
  } from './constants.js';
24
- import { processComponent, abortComponentFetch } from './component.js';
24
+ import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState } from './component.js';
25
25
  import { debugLog } from './debug.js';
26
26
  import { shouldCleanup, cleanup } from './cleanup.js';
27
+ import { reconcile } from './reconcile.js';
27
28
  import {
28
29
  buildHyperspeedManifest,
29
30
  hyperspeedManifest,
@@ -617,6 +618,22 @@ const main = (s, config = {}, stringSelector = '') => {
617
618
  enumerable: false,
618
619
  });
619
620
 
621
+ // Promise that resolves after the ready hook fires and all ready callbacks
622
+ // have run. Lets late subscribers await readiness without missing the event:
623
+ // `await $.ready`. Non-enumerable so it won't leak into state snapshots.
624
+ let resolveReady;
625
+ Object.defineProperty($, 'ready', {
626
+ value: new Promise((resolve) => { resolveReady = resolve; }),
627
+ enumerable: false,
628
+ });
629
+
630
+ // Reconcile a managed subtree against new source HTML. Opt-in entry point;
631
+ // dormant unless called (so hot paths and benchmarks are unaffected).
632
+ Object.defineProperty($, 'reconcile', {
633
+ value: reconcile,
634
+ enumerable: false,
635
+ });
636
+
620
637
  // Initial hydration - pass plain values so iteration can do reference comparison
621
638
  const initialState = extractPlainValue($);
622
639
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -696,6 +713,14 @@ const main = (s, config = {}, stringSelector = '') => {
696
713
  let evaluatedCount = 0;
697
714
  let addedElementsList = []; // Track all added elements for verbose output
698
715
 
716
+ // Collect data-vibe-component-id values across ALL removed subtrees in this
717
+ // batch before doing any per-node work, so we can evict their state after
718
+ // the DOM mutations have been applied.
719
+ const removedComponentIds = new Set();
720
+ mutations.forEach(({ removedNodes: removedNodesList }) => {
721
+ removedNodesList.forEach((node) => collectComponentIds(node, removedComponentIds));
722
+ });
723
+
699
724
  mutations.forEach(({ addedNodes: addedNodesList, removedNodes: removedNodesList, target }) => {
700
725
  removedNodesList.forEach((node) => {
701
726
  // If this is a component element with pending fetch, abort it
@@ -761,29 +786,20 @@ const main = (s, config = {}, stringSelector = '') => {
761
786
  return;
762
787
  }
763
788
 
764
- const entry = Object.entries(manifest).find(([_, element]) => element === target);
765
-
766
- // If parent isn't tracked, this node is outside the reactive scope
767
- if (!entry) return;
768
-
769
- const [dotAnnotation] = entry;
770
- const picked = navigateTree(parsedTree, dotAnnotation);
771
-
772
- // If we can't navigate to the parent in the tree, skip
773
- if (!picked) return;
774
-
775
- // If parent has no element reference, re-parse from the actual DOM element
776
- if (!picked.element) {
777
- picked.element = target;
789
+ // Capture raw slot content of nested <component src> elements BEFORE parse runs.
790
+ // Parse creates conditional nodes from <!-- if --> comments, and renderConditional
791
+ // later removes the template nodes between the comments. Without capturing slot
792
+ // content first, conditionals inside a component's slot content lose their branch
793
+ // templates, breaking reactive updates.
794
+ if (node.nodeType === 1) {
795
+ node.querySelectorAll('component[src], div.component[src]').forEach((el) => {
796
+ if (el._vibeSlotContent === undefined) el._vibeSlotContent = el.innerHTML;
797
+ });
778
798
  }
779
799
 
780
- // Parse the newly added node (use monotonically increasing counter for deterministic key)
781
- // Initialize counter if it doesn't exist
782
- if (!picked._nextChildIndex) {
783
- picked._nextChildIndex = Object.keys(picked.children).length;
784
- }
785
- const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
786
- picked._nextChildIndex++; // Always increment, never decrement
800
+ const entry = Object.entries(manifest).find(([_, element]) => element === target);
801
+
802
+ // Parse the newly added node
787
803
  const parsedNode = parse(node);
788
804
 
789
805
  // Accumulate skipped stats
@@ -791,19 +807,42 @@ const main = (s, config = {}, stringSelector = '') => {
791
807
  totalSkipped += parsedNode.stats.skipped;
792
808
  }
793
809
 
794
- // Update parent's parsed HTML (only once per parent)
795
- if (!parsedParents) parsedParents = new Set();
796
- if (!parsedParents.has(picked)) {
797
- const { parsed } = parse(picked.element);
798
- picked.parsed = parsed;
799
- parsedParents.add(picked);
800
- }
810
+ // If parent is tracked in the manifest, register this new node in the parsed tree.
811
+ // (If not e.g. mutations inside an iteration instance whose rows aren't in the
812
+ // global manifest — we still hydrate the node below; we just skip tree/manifest
813
+ // registration since there's no tree branch to attach to.)
814
+ if (entry) {
815
+ const [dotAnnotation] = entry;
816
+ const picked = navigateTree(parsedTree, dotAnnotation);
817
+
818
+ if (picked) {
819
+ if (!picked.element) {
820
+ picked.element = target;
821
+ }
822
+
823
+ // Parse the newly added node (use monotonically increasing counter for deterministic key)
824
+ // Initialize counter if it doesn't exist
825
+ if (!picked._nextChildIndex) {
826
+ picked._nextChildIndex = Object.keys(picked.children).length;
827
+ }
828
+ const name = `${node.nodeName.toLowerCase()}_${picked._nextChildIndex}`;
829
+ picked._nextChildIndex++; // Always increment, never decrement
830
+
831
+ // Update parent's parsed HTML (only once per parent)
832
+ if (!parsedParents) parsedParents = new Set();
833
+ if (!parsedParents.has(picked)) {
834
+ const { parsed } = parse(picked.element);
835
+ picked.parsed = parsed;
836
+ parsedParents.add(picked);
837
+ }
801
838
 
802
- // Add the parsed node to parent's children
803
- picked.children[name] = parsedNode;
839
+ // Add the parsed node to parent's children
840
+ picked.children[name] = parsedNode;
804
841
 
805
- // Recursively add node and all descendants to manifest
806
- addToManifest(parsedNode, manifest, `${dotAnnotation}.${name}`);
842
+ // Recursively add node and all descendants to manifest
843
+ addToManifest(parsedNode, manifest, `${dotAnnotation}.${name}`);
844
+ }
845
+ }
807
846
 
808
847
  // Run core loop for the new node (parse already done, hydrate → conditionals → iterate)
809
848
  const counts = processCoreLoop(node, parsedNode, $, manifest, true, debug);
@@ -823,6 +862,9 @@ const main = (s, config = {}, stringSelector = '') => {
823
862
  });
824
863
  });
825
864
 
865
+ // CLEANUP OF CURRENT STATE
866
+ releaseOrphanedComponentState(removedComponentIds);
867
+
826
868
  // Fire hooks once after all mutations are processed (not per-node)
827
869
  if (hadChanges) {
828
870
  if (addedElements > 0 || addedNodes > 0 || removedElements > 0 || removedNodes > 0) {
@@ -1019,6 +1061,8 @@ const main = (s, config = {}, stringSelector = '') => {
1019
1061
  console.error('[vibe] Error in ready hook:', error);
1020
1062
  }
1021
1063
  });
1064
+ // Resolve $.ready promise after all ready callbacks have run
1065
+ resolveReady();
1022
1066
  }
1023
1067
  };
1024
1068