@ape-egg/vibe 1.9.0 → 1.9.5

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,11 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_FETCH, DEHYDRATE_CLASS_OR_ATTR, BINDING_REGEX } from './constants.js';
2
+ import {
3
+ PHASE_FETCH,
4
+ DEHYDRATE_CLASS_OR_ATTR,
5
+ BINDING_REGEX,
6
+ THIS_PROP_REGEX,
7
+ STATE_THIS_PROP_REGEX,
8
+ } from './constants.js';
3
9
  import { evalInScope } from './utils.js';
4
10
 
5
11
  // Deterministic component counter
@@ -35,9 +41,49 @@ export const releaseOrphanedComponentState = (collectedIds) => {
35
41
  delete window.__vibeComponents?.[id];
36
42
  // CLEANUP OF CURRENT STATE
37
43
  delete window.$[id];
44
+ runComponentCleanups(id);
38
45
  }
39
46
  };
40
47
 
48
+ // Listener registry: maps componentId → array of unsubscribe callbacks
49
+ // returned from `$.on(...)` calls inside the component's `<script>`.
50
+ // Re-running a script for the same id (HMR remount with reused ids) or
51
+ // unmounting the component fires the callbacks so the previous evaluation's
52
+ // listeners don't accumulate alongside fresh registrations.
53
+ export const runComponentCleanups = (componentId) => {
54
+ const cleanups = window.__vibeComponentCleanups?.[componentId];
55
+ if (!cleanups) return;
56
+ for (let i = 0; i < cleanups.length; i++) cleanups[i]();
57
+ delete window.__vibeComponentCleanups[componentId];
58
+ };
59
+
60
+ // Per-script `$` Proxy. Bare `$` references in a component's <script> resolve
61
+ // to this Proxy (the function parameter shadows the global), so every
62
+ // `$.on(...)` registration is automatically attributed to `componentId` via
63
+ // the closure — no global flag, async-safe across `await` boundaries because
64
+ // the closure binds the id, not a shared variable.
65
+ const createScopedDollar = (componentId) => {
66
+ const dollar = window.$;
67
+ if (!dollar) return dollar;
68
+ return new Proxy(dollar, {
69
+ get(target, prop, receiver) {
70
+ if (prop === 'on') {
71
+ return (event, callback) => {
72
+ const unsub = target.on(event, callback);
73
+ if (!window.__vibeComponentCleanups) window.__vibeComponentCleanups = {};
74
+ const slot = window.__vibeComponentCleanups[componentId] || (window.__vibeComponentCleanups[componentId] = []);
75
+ slot.push(unsub);
76
+ return unsub;
77
+ };
78
+ }
79
+ return Reflect.get(target, prop, receiver);
80
+ },
81
+ set(target, prop, value, receiver) {
82
+ return Reflect.set(target, prop, value, receiver);
83
+ },
84
+ });
85
+ };
86
+
41
87
  // Walk a node subtree (element or node list) and collect all
42
88
  // `data-vibe-component-id` values found on the node and its descendants.
43
89
  export const collectComponentIds = (node, into = new Set()) => {
@@ -79,6 +125,168 @@ const isNestedInUnprocessedComponent = (el, rootElement) => {
79
125
  return false;
80
126
  };
81
127
 
128
+ // Rewrite `@[this.x...]` and `$.this.x...` inside element's text/attrs to use
129
+ // componentId. Shared between script-execution path and pure-render path.
130
+ //
131
+ // Within `@[...]` bindings we rewrite every `this.X` reference (preserving
132
+ // any trailing `.Y.Z` chain), so `@[this.user.name]` and `@[this.x + 1]` both
133
+ // resolve correctly. Outside bindings — i.e. event handler attribute bodies
134
+ // like `onclick="$.this.mode = 'edit'"` — only `$.this.X` writes are
135
+ // rewritten; bare `this.X` reads in event handlers are handled later by
136
+ // parse.js (which preserves DOM properties like `this.value`).
137
+ const rewriteBindingsInString = (str, componentId) =>
138
+ str.replace(BINDING_REGEX, (match, expr) => {
139
+ const rewritten = expr.replace(THIS_PROP_REGEX, `${componentId}.$1`);
140
+ return rewritten === expr ? match : `@[${rewritten}]`;
141
+ });
142
+
143
+ const rewriteThisBindings = (element, componentId) => {
144
+ Array.from(element.childNodes).forEach((node) => {
145
+ if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
146
+ node.textContent = rewriteBindingsInString(node.textContent, componentId);
147
+ }
148
+ });
149
+
150
+ Array.from(element.attributes || []).forEach((attr) => {
151
+ if (attr.value.includes('@[this.')) {
152
+ attr.value = rewriteBindingsInString(attr.value, componentId);
153
+ }
154
+ if (attr.value.includes('$.this.')) {
155
+ attr.value = attr.value.replace(STATE_THIS_PROP_REGEX, `$.${componentId}.$1`);
156
+ }
157
+ });
158
+
159
+ Array.from(element.children).forEach((child) => {
160
+ rewriteThisBindings(child, componentId);
161
+ });
162
+ };
163
+
164
+ // Tag siblings of a <script> with componentId and rewrite `this.` bindings.
165
+ // Runs BEFORE script.remove() so nextElementSibling is valid. Shared by
166
+ // processSingle (executing path) and renderComponentTemplate (pure path).
167
+ const tagScriptSiblings = (script, componentId) => {
168
+ let sibling = script.nextElementSibling;
169
+ while (sibling) {
170
+ if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
171
+ break;
172
+ }
173
+ sibling.setAttribute('data-vibe-component-id', componentId);
174
+ rewriteThisBindings(sibling, componentId);
175
+ sibling = sibling.nextElementSibling;
176
+ }
177
+ };
178
+
179
+ // Substitute props + inline slot content into a pre-processed temp container.
180
+ // Returns the final processed HTML string. Shared by processSingle.finalize()
181
+ // (runtime mount) and renderComponentTemplate (surgical HMR).
182
+ const renderPropsAndSlot = (temp, props, slotHtml) => {
183
+ let transformedHtml = temp.innerHTML;
184
+
185
+ const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
186
+ const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
187
+
188
+ // HTML's parser lowercases attribute names, so a consumer-written
189
+ // `<component dndDisabled>` arrives here with propName `dnddisabled` while
190
+ // the component template author wrote `dndDisabled`. Match identifiers and
191
+ // bindings case-insensitively so both sides line up. Word-boundary
192
+ // lookbehind/lookahead still hold (they're case-agnostic), so
193
+ // `dnddisabled` won't bleed into `dndDisabledAlt`.
194
+ Object.entries(props).forEach(([propName, propValue]) => {
195
+ const bindingMatch = propValue.match(/^@\[(.+)\]$/);
196
+ const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'gi');
197
+ const idRegex = new RegExp(
198
+ `(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
199
+ 'gi'
200
+ );
201
+
202
+ const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
203
+
204
+ if (bindingMatch) {
205
+ const path = bindingMatch[1];
206
+ transformedHtml = transformedHtml.replace(exactPattern, `@[${path}]`);
207
+ transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
208
+ const rewritten = substituteInExpr(expr, `(${path})`);
209
+ return rewritten === expr ? match : `@[${rewritten}]`;
210
+ });
211
+ transformedHtml = transformedHtml.replace(
212
+ DIRECTIVE_COMMENT_REGEX,
213
+ (match, kw, expr) => {
214
+ const replacement = kw === 'each' ? path : `(${path})`;
215
+ const rewritten = substituteInExpr(expr, replacement);
216
+ return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
217
+ }
218
+ );
219
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
220
+ transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
221
+ const rewritten = body.replace(stateRegex, `$.${path}`);
222
+ return rewritten === body ? match : `on${evName}="${rewritten}"`;
223
+ });
224
+ } else {
225
+ transformedHtml = transformedHtml.replace(exactPattern, propValue);
226
+ const isNumeric =
227
+ typeof propValue === 'string' && /^-?\d+(\.\d+)?(e[+-]?\d+)?$/i.test(propValue);
228
+ const isBooleanAttr = propValue === '';
229
+ const literal = isBooleanAttr
230
+ ? 'true'
231
+ : typeof propValue === 'string' && !isNumeric
232
+ ? JSON.stringify(propValue)
233
+ : String(propValue);
234
+ transformedHtml = transformedHtml.replace(BINDING_REGEX, (match, expr) => {
235
+ const rewritten = substituteInExpr(expr, literal);
236
+ return rewritten === expr ? match : `@[${rewritten}]`;
237
+ });
238
+ transformedHtml = transformedHtml.replace(
239
+ DIRECTIVE_COMMENT_REGEX,
240
+ (match, kw, expr) => {
241
+ const rewritten = substituteInExpr(expr, literal);
242
+ return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
243
+ }
244
+ );
245
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
246
+ transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
247
+ const rewritten = body.replace(stateRegex, `$.${literal}`);
248
+ return rewritten === body ? match : `on${evName}="${rewritten}"`;
249
+ });
250
+ }
251
+ });
252
+
253
+ const children = (slotHtml || '').trim();
254
+ if (children) {
255
+ transformedHtml = transformedHtml.replace(
256
+ /<slot(\s[^>]*)?>\s*<\/slot>/g,
257
+ (_, attrs) => `<slot${attrs || ''}>${children}</slot>`
258
+ );
259
+ transformedHtml = transformedHtml.replace(
260
+ /<slot(\s[^>]*)?\/>/g,
261
+ (_, attrs) => `<slot${attrs || ''}>${children}</slot>`
262
+ );
263
+ }
264
+
265
+ return transformedHtml;
266
+ };
267
+
268
+ // Pure-render path for surgical HMR. Takes a component's raw template HTML
269
+ // plus the live instance's props, slot, and existing componentIds. Returns
270
+ // the processed HTML string that $.reconcile can diff against the live
271
+ // wrapper's children. Scripts are NOT executed — callers are responsible
272
+ // for deciding whether to preserve the existing state (reuse componentIds)
273
+ // or trigger a full re-mount (new componentIds).
274
+ export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
275
+ const { componentIds = [] } = options;
276
+ const temp = document.createElement('div');
277
+ temp.innerHTML = rawHtml;
278
+
279
+ const idsToReuse = [...componentIds];
280
+ const moduleScripts = temp.querySelectorAll('script[type="module"]');
281
+ for (const script of moduleScripts) {
282
+ const componentId = idsToReuse.shift() || generateComponentId();
283
+ tagScriptSiblings(script, componentId);
284
+ script.remove();
285
+ }
286
+
287
+ return renderPropsAndSlot(temp, props, slotHtml);
288
+ };
289
+
82
290
  // Process a single component element: fetch HTML, execute scripts, replace DOM
83
291
  const processSingle = (el, debug) => {
84
292
  // Skip dehydrated components
@@ -119,6 +327,21 @@ const processSingle = (el, debug) => {
119
327
  // Process each script — collect async tasks if any have imports
120
328
  const asyncTasks = [];
121
329
 
330
+ // Track every componentId registered during this fetch. If the host
331
+ // element is detached before finalize runs (e.g. a conditional unmounted
332
+ // mid-fetch, or the user navigated away), we release these state
333
+ // buckets — otherwise component({...}) leaks state to `$` for DOM that
334
+ // never reaches the document.
335
+ const registeredComponentIds = [];
336
+
337
+ // First componentId encountered — applied to the wrapper itself so
338
+ // directives living between top-level sibling roots (e.g. a comment
339
+ // marker for `<!-- if this.X -->`) can resolve component scope via
340
+ // closest('[data-vibe-component-id]'). Without this, multi-root
341
+ // templates have scope-less wrappers and top-level `this.` references
342
+ // fall through to global state.
343
+ let firstComponentId = null;
344
+
122
345
  for (const script of moduleScripts) {
123
346
  let scriptContent = script.textContent?.trim() || '';
124
347
  if (!scriptContent) continue;
@@ -135,7 +358,11 @@ const processSingle = (el, debug) => {
135
358
 
136
359
  if (hasImports) {
137
360
  // Rewrite remaining imports to dynamic await import()
138
- // Order matters: default → named → namespace → side-effect (most specific first)
361
+ // Order matters: combined → default → named → namespace → side-effect (most specific first)
362
+ scriptContent = scriptContent.replace(
363
+ /import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
364
+ 'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
365
+ );
139
366
  scriptContent = scriptContent.replace(
140
367
  /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
141
368
  'const $1 = (await import($2)).default;'
@@ -158,6 +385,8 @@ const processSingle = (el, debug) => {
158
385
  const reuseIds = el._vibeReuseComponentIds;
159
386
  const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
160
387
 
388
+ if (firstComponentId === null) firstComponentId = componentId;
389
+
161
390
  // Provide a component() function that registers state for this component.
162
391
  // Sibling tagging is handled below (before script.remove()) so it works
163
392
  // for both sync and async scripts.
@@ -165,66 +394,43 @@ const processSingle = (el, debug) => {
165
394
  if (!window.__vibeComponents) window.__vibeComponents = {};
166
395
  window.__vibeComponents[componentId] = state;
167
396
  if (window.$) window.$[componentId] = state;
397
+ if (!registeredComponentIds.includes(componentId)) {
398
+ registeredComponentIds.push(componentId);
399
+ }
168
400
  };
169
401
 
402
+ // Re-running the script for a reused componentId (HMR remount) must
403
+ // tear down listeners from the previous evaluation before the fresh
404
+ // script registers new ones. Without this, every cycle stacks another
405
+ // listener on top of the stale closures and a single reactive tick
406
+ // fires N callbacks instead of one.
407
+ runComponentCleanups(componentId);
408
+
409
+ // Provide a per-script `$` whose `.on(...)` registers cleanups under
410
+ // this componentId. Bare `$` in the script body resolves to this
411
+ // Proxy (the function parameter shadows the global), so listener
412
+ // registrations are auto-tracked across `await` boundaries via the
413
+ // closure — no opt-in required.
414
+ const scopedDollar = createScopedDollar(componentId);
415
+
170
416
  // Execute script with component() function in scope
171
417
  try {
172
418
  if (hasImports) {
173
419
  // Async execution for scripts with imports
174
420
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
175
- asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
421
+ asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
176
422
  } else {
177
423
  // Synchronous execution for scripts without imports (preserves boot timing)
178
- const executeFn = new Function('component', scriptContent);
179
- executeFn(componentFn);
424
+ const executeFn = new Function('$', 'component', scriptContent);
425
+ executeFn(scopedDollar, componentFn);
180
426
  }
181
427
  } catch (e) {
182
428
  console.warn('[vibe] Failed to execute component script:', e);
183
429
  }
184
430
 
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.
188
- const rewriteThisBindings = (element) => {
189
- const bindingRegex = /@\[this\.(\w+)\]/g;
190
- const writeRegex = /\$\.this\.(\w+)/g;
191
-
192
- // Rewrite in text nodes
193
- Array.from(element.childNodes).forEach((node) => {
194
- if (node.nodeType === Node.TEXT_NODE && node.textContent.includes('@[this.')) {
195
- node.textContent = node.textContent.replace(bindingRegex, `@[${componentId}.$1]`);
196
- }
197
- });
198
-
199
- // Rewrite in attributes — both @[this.x] bindings and $.this.x in event handlers
200
- Array.from(element.attributes || []).forEach((attr) => {
201
- if (attr.value.includes('@[this.')) {
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`);
206
- }
207
- });
208
-
209
- // Recurse into children
210
- Array.from(element.children).forEach((child) => {
211
- rewriteThisBindings(child);
212
- });
213
- };
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.
219
- let sibling = script.nextElementSibling;
220
- while (sibling) {
221
- if (sibling.tagName === 'SCRIPT' && sibling.getAttribute('type') === 'module') {
222
- break;
223
- }
224
- sibling.setAttribute('data-vibe-component-id', componentId);
225
- rewriteThisBindings(sibling);
226
- sibling = sibling.nextElementSibling;
227
- }
431
+ // Tag siblings + rewrite this. bindings using shared helper. Runs
432
+ // BEFORE script.remove() so nextElementSibling is valid.
433
+ tagScriptSiblings(script, componentId);
228
434
 
229
435
  // Remove script from temp (we executed it manually)
230
436
  script.remove();
@@ -232,106 +438,8 @@ const processSingle = (el, debug) => {
232
438
 
233
439
  // Finalize: props, slots, DOM replacement
234
440
  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
- );
334
- }
441
+ // Delegate prop substitution + slot inlining to shared helper.
442
+ const transformedHtml = renderPropsAndSlot(temp, props, children);
335
443
 
336
444
  // Clean up pending fetch tracker
337
445
  pendingFetches.delete(el);
@@ -350,12 +458,58 @@ const processSingle = (el, debug) => {
350
458
  }
351
459
 
352
460
  newWrapper.innerHTML = transformedHtml;
461
+ if (firstComponentId !== null) {
462
+ newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
463
+ }
464
+ // Stash the raw source so the HMR plugin can establish a baseline
465
+ // script hash on the very first update — without this, the first
466
+ // save after page load would always fall back to re-mount (since
467
+ // the plugin would have nothing to compare against). Vibe itself
468
+ // never reads this; it's purely for the plugin spy.
469
+ newWrapper._vibeRawSource = html;
470
+ // Transfer iteration-prop registry ownership from the soon-to-be-
471
+ // detached `<component src>` to the new wrapper. The detached element
472
+ // would otherwise trigger releaseOrphanedIterationProps and free the
473
+ // registry slots that the inlined template's bindings still reference,
474
+ // causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
475
+ // on the next hydrate.
476
+ if (el._vibeIterPropIds) {
477
+ newWrapper._vibeIterPropIds = el._vibeIterPropIds;
478
+ newWrapper.setAttribute('data-vibe-iter-prop', '');
479
+ el._vibeIterPropIds = null;
480
+ el.removeAttribute('data-vibe-iter-prop');
481
+ }
482
+ // Transfer the original prop expressions too, so the iteration's
483
+ // update path can re-evaluate them against the row's new scope and
484
+ // refresh the registry slots in place — letting the inlined
485
+ // component's bindings react without rebuilding the row's DOM.
486
+ if (el._vibeIterPropExprs) {
487
+ newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
488
+ el._vibeIterPropExprs = null;
489
+ }
490
+ // Back-pointer from the soon-to-be-detached `<component src>` to
491
+ // the new wrapper. The iteration's `instance.clonedNodes` still
492
+ // references the original element; follow this link to reach the
493
+ // live wrapper when refreshing registry slots / re-hydrating.
494
+ // The wrapper's `_vibeIterTree` (set later by processMutations after
495
+ // renderAllConditionals/Iterations populated runtime data) is what
496
+ // iterate.js's update path uses to re-evaluate inlined bindings on
497
+ // each row update.
498
+ el._vibeReplacedBy = newWrapper;
353
499
  el.replaceWith(newWrapper);
354
500
  debugLog(PHASE_FETCH, src, debug);
355
501
 
356
502
  // MutationObserver handles parsing and hydrating the new content.
357
503
  // Branch nodes are registered in the manifest by mountBranch,
358
504
  // so the observer can find parents even inside conditional branches.
505
+ } else {
506
+ // Element was detached before finalize ran (conditional unmounted
507
+ // during fetch, parent removed, etc). Release any state component()
508
+ // calls registered — otherwise it leaks on `$` forever.
509
+ for (const id of registeredComponentIds) {
510
+ delete window.__vibeComponents?.[id];
511
+ if (window.$) delete window.$[id];
512
+ }
359
513
  }
360
514
  };
361
515
 
@@ -1,7 +1,7 @@
1
1
  import parse from './parse.js';
2
2
  import affected from './affected.js';
3
3
  import hydrate from './hydrate.js';
4
- import { createScopedState, renderAllIterations, initializeBlock } from './iterate.js';
4
+ import { createScopedState, renderAllIterations, initializeBlock, resolveIterationComponentProps } from './iterate.js';
5
5
  import { evalInScope } from './utils.js';
6
6
  import { collectComponentIds, releaseOrphanedComponentState } from './component.js';
7
7
 
@@ -98,6 +98,16 @@ export const renderAllConditionals = (tree, state, manifest, parentScope = {}) =
98
98
  export const renderConditional = (node, state, manifest, parentScope = {}) => {
99
99
  const { expression, startComment, endComment, branches } = node.meta;
100
100
 
101
+ // Mark conditionals that live inside an iteration row so update-time branch
102
+ // flips can route `<component src>` props through the iteration-prop
103
+ // registry. Skipping this for top-level conditionals keeps their props as
104
+ // live `@[stateKey]` bindings — which is what global state-change reactivity
105
+ // depends on (the registry path snapshots a value and doesn't react to
106
+ // global state changes on its own).
107
+ if (Object.keys(parentScope).length > 0) {
108
+ node.runtime.inIteration = true;
109
+ }
110
+
101
111
  // Check if already rendered (using marker on comment node)
102
112
  // @ts-ignore - adding custom property to comment node
103
113
  if (startComment.__vibeRendered) {
@@ -158,12 +168,29 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
158
168
  const scopedState =
159
169
  Object.keys(parentScope).length > 0 ? createScopedState(state, parentScope) : state;
160
170
 
161
- // Initialize block (clone, parse, hydrate)
171
+ // Initialize block (clone, parse, hydrate). Pass the enclosing loop aliases so
172
+ // the re-parse rewrites loop-scoped handlers in this branch — including ones
173
+ // nested deeper in further conditionals, which the parser reaches by carrying
174
+ // the alias set down. `scopeAliases` is set at parse time and persists, so this
175
+ // works on both the initial-render and update (hydrate) mount paths.
176
+ const aliasSet = node.meta.scopeAliases?.length ? new Set(node.meta.scopeAliases) : undefined;
162
177
  const {
163
178
  element: firstElement,
164
179
  tree: branchTree,
165
180
  clonedNodes,
166
- } = initializeBlock(templateContent, scopedState);
181
+ } = initializeBlock(templateContent, scopedState, null, null, aliasSet);
182
+
183
+ // For conditionals living inside an iteration, route any `<component src>`
184
+ // props through the iteration-prop registry against the active scopedState
185
+ // (which carries the iteration's local vars). Without this, processSingle
186
+ // would inline `@[item.x]` bindings into the component template, where
187
+ // `item` isn't reachable in global scope and props resolve to undefined.
188
+ // Skipped for top-level conditionals because their props reference live
189
+ // global-state bindings — going through the registry would snapshot the
190
+ // value and break reactivity.
191
+ if (node.runtime.inIteration) {
192
+ resolveIterationComponentProps(clonedNodes, scopedState);
193
+ }
167
194
 
168
195
  // Insert cloned nodes into DOM and register in branch registry
169
196
  clonedNodes.forEach((clonedNode, i) => {
@@ -172,6 +199,21 @@ const mountBranch = (node, branchData, state, manifest, parentScope) => {
172
199
  if (clonedNode.nodeType === 1) managedNodes.add(clonedNode);
173
200
  });
174
201
 
202
+ // Branch content is mounted outside the enclosing iteration instance's
203
+ // clonedNodes, so it doesn't inherit the instance's `__vibeScope` stamp by DOM
204
+ // ancestry. When this conditional lives inside a loop, stamp `scopedState` —
205
+ // the same scope that hydrates the branch's `@[alias.x]` bindings — onto the
206
+ // branch's root elements so loop-scoped `$scope(this,'alias')` handlers resolve.
207
+ // Gate on `scopeAliases` (set at parse time, persists) rather than the runtime
208
+ // parentScope/inIteration, because the update path (hydrate -> updateConditional)
209
+ // and deeper-nested conditionals mount with an empty parentScope yet still flow
210
+ // the iteration's scoped state in as `state`.
211
+ if (node.meta.scopeAliases?.length) {
212
+ for (let i = 0; i < clonedNodes.length; i++) {
213
+ if (clonedNodes[i].nodeType === 1) clonedNodes[i].__vibeScope = scopedState;
214
+ }
215
+ }
216
+
175
217
  // Integrate branch tree into the conditional node's children and manifest.
176
218
  // This makes branch content visible to the main update loop (hydrate,
177
219
  // renderAllConditionals, renderAllIterations) and to MutationObserver
@@ -210,11 +210,18 @@ 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 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*$/;
213
+ // Regex for parsing iteration comment syntax. Supported forms:
214
+ // <!-- each items as item -->
215
+ // <!-- each items as item, index -->
216
+ // <!-- each items as item (item.id) --> // explicit key
217
+ // <!-- each items as item (item.id), index --> // key + index
218
+ // Capture groups: arrayPath, itemAlias, keyExpr (optional), indexAlias (optional).
219
+ // The array expression can be any JS: a state path, a window global, a method
220
+ // call, or an inline literal. The optional key expression is evaluated per
221
+ // item against scoped state to produce a stable identity for diffing — this
222
+ // keeps survivors stable when earlier items are removed (otherwise the
223
+ // fallback hash key embeds the index and triggers bulk re-render).
224
+ export const ITERATION_REGEX = /^each\s+(.+)\s+as\s+(\w+)(?:\s*\(\s*([^)]+?)\s*\))?(?:\s*,\s*(\w+))?\s*$/;
218
225
 
219
226
  // Regex for detecting start of iteration comment
220
227
  export const ITERATION_START_REGEX = /^each\s+/;
@@ -224,3 +231,15 @@ export const CONDITIONAL_REGEX = /^if\s+(.+)$/;
224
231
 
225
232
  // Regex for detecting start of conditional comment
226
233
  export const CONDITIONAL_START_REGEX = /^if\s+/;
234
+
235
+ // Regex for rewriting component-local `this.X` references to the component's
236
+ // state path. Captures the leading identifier only — any trailing `.Y.Z`
237
+ // chain stays attached after replacement, so `this.user.name` becomes
238
+ // `<componentId>.user.name`. Used in expression bodies (bindings, event
239
+ // handlers, conditional/iteration directives).
240
+ export const THIS_PROP_REGEX = /\bthis\.(\w+)/g;
241
+
242
+ // Regex for rewriting `$.this.X` writes (proxy assignment from event handlers)
243
+ // to the component's write path. Same prefix-only semantics as THIS_PROP_REGEX
244
+ // — `$.this.user.name = x` becomes `$.<componentId>.user.name = x`.
245
+ export const STATE_THIS_PROP_REGEX = /\$\.this\.(\w+)/g;