@ape-egg/vibe 1.9.1 → 1.9.6

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,46 +1,75 @@
1
- import { resolvePath, deepEqual } from './iteration-utils.js';
2
- import { extractDependencies } from './conditionals.js';
3
- import { BINDING_REGEX } from './constants.js';
4
- import { evalInScope, resolveThisPath } from './utils.js';
1
+ import { resolvePath, deepEqual } from "./iteration-utils.js";
2
+ import { extractDependencies } from "./conditionals.js";
3
+ import { BINDING_REGEX } from "./constants.js";
4
+ import { evalInScope, resolveThisPath } from "./utils.js";
5
5
 
6
6
  // Evaluate conditional expression
7
- const evaluateCondition = (expression, state, element = null) => !!evalInScope(expression, state, element);
7
+ const evaluateCondition = (expression, state, element = null) =>
8
+ !!evalInScope(expression, state, element);
8
9
 
9
10
  // Helper function to check if a match references a specific key.
10
11
  // Fast path: exact match or property access (`user.name` matches `user`).
11
12
  // Slow path: word-boundary search for complex expressions like `Math.floor(coins / 100)`
12
13
  // where the key appears as an identifier anywhere in the expression.
13
- const isIdentChar = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_' || c === '$';
14
+ const isIdentChar = (c) =>
15
+ (c >= "a" && c <= "z") ||
16
+ (c >= "A" && c <= "Z") ||
17
+ (c >= "0" && c <= "9") ||
18
+ c === "_" ||
19
+ c === "$";
14
20
 
15
21
  const matchesKey = (matchStr, key) => {
16
- if (matchStr === key || matchStr.startsWith(key + '.') || matchStr.startsWith(key + '[')) return true;
22
+ if (
23
+ matchStr === key ||
24
+ matchStr.startsWith(key + ".") ||
25
+ matchStr.startsWith(key + "[")
26
+ )
27
+ return true;
17
28
 
18
29
  // Search for key as a standalone identifier (word boundaries on both sides)
19
30
  let i = 0;
20
31
  while ((i = matchStr.indexOf(key, i)) !== -1) {
21
- const before = i === 0 ? '' : matchStr[i - 1];
22
- const after = i + key.length >= matchStr.length ? '' : matchStr[i + key.length];
32
+ const before = i === 0 ? "" : matchStr[i - 1];
33
+ const after =
34
+ i + key.length >= matchStr.length ? "" : matchStr[i + key.length];
23
35
  if (!isIdentChar(before) && !isIdentChar(after)) return true;
24
36
  i += key.length;
25
37
  }
26
38
  return false;
27
39
  };
28
40
 
29
- const recursive = (tree, state, newState, affected, scopedStateForHydration = null, depth = 0) => {
41
+ const recursive = (
42
+ tree,
43
+ state,
44
+ newState,
45
+ affected,
46
+ scopedStateForHydration = null,
47
+ depth = 0,
48
+ ) => {
30
49
  // Handle iteration nodes specially
31
- if (tree.type === 'iteration') {
32
- const resolvedExpr = resolveThisPath(tree.meta.arrayPath, tree.meta.startComment?.parentElement);
33
-
34
- const oldArray = evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ?? resolvePath(state, resolvedExpr);
35
- const newArray = evalInScope(resolvedExpr, newState, tree.meta.startComment?.parentElement) ?? resolvePath(newState, resolvedExpr);
50
+ if (tree.type === "iteration") {
51
+ const resolvedExpr = resolveThisPath(
52
+ tree.meta.arrayPath,
53
+ tree.meta.startComment?.parentElement,
54
+ );
55
+
56
+ const oldArray =
57
+ evalInScope(resolvedExpr, state, tree.meta.startComment?.parentElement) ??
58
+ resolvePath(state, resolvedExpr);
59
+ const newArray =
60
+ evalInScope(
61
+ resolvedExpr,
62
+ newState,
63
+ tree.meta.startComment?.parentElement,
64
+ ) ?? resolvePath(newState, resolvedExpr);
36
65
 
37
66
  // Fast path: reference comparison (arrays are typically replaced, not mutated)
38
67
  // This avoids expensive O(n) deepEqual for large arrays
39
68
  if (oldArray !== newArray) {
40
69
  affected.push({
41
- type: 'iteration',
70
+ type: "iteration",
42
71
  node: tree,
43
- changeType: 'array',
72
+ changeType: "array",
44
73
  });
45
74
  return affected;
46
75
  }
@@ -51,16 +80,26 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
51
80
  // Compiled iterations have instances without tree/scopedState — the batch function
52
81
  // may reference global state keys (e.g., `selectedCategory` in a button binding).
53
82
  // If any state key changed, trigger a rebuild so the batch function re-evaluates.
54
- const hasCompiledInstances = tree.compiled && tree.runtime.instances.length > 0
55
- && !tree.runtime.instances[0].tree;
56
-
57
- if (hasCompiledInstances) {
83
+ // Instances rendered without a per-row parsed tree compiled (batch
84
+ // fn from the manifest) or runtime batch (fast path for simple
85
+ // templates) — can't be walked by the recursive descent below to
86
+ // detect nested outer-state bindings. Mark the whole iteration as
87
+ // affected so updateIteration runs and rebuilds the rows.
88
+ const hasTreelessInstances =
89
+ tree.runtime.instances.length > 0 &&
90
+ !tree.runtime.instances[0].tree;
91
+
92
+ if (hasTreelessInstances) {
58
93
  const isInitialHydration = state === newState;
59
94
  if (!isInitialHydration) {
60
95
  const stateKeys = Object.keys(state);
61
- const hasChangedKey = stateKeys.some(k => state[k] !== newState[k]);
96
+ const hasChangedKey = stateKeys.some((k) => state[k] !== newState[k]);
62
97
  if (hasChangedKey) {
63
- affected.push({ type: 'iteration', node: tree, changeType: 'array' });
98
+ affected.push({
99
+ type: "iteration",
100
+ node: tree,
101
+ changeType: "array",
102
+ });
64
103
  return affected;
65
104
  }
66
105
  }
@@ -68,15 +107,28 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
68
107
 
69
108
  for (const instance of tree.runtime.instances) {
70
109
  if (instance.tree && instance.scopedState) {
71
- // Build plain-object snapshots for comparison. scopedState is a live Proxy
72
- // that reflects current global state spreading it gives us the local vars
73
- // (cat, index) plus current global values. We then override globals with the
74
- // actual old (state) / new (newState) values so binding comparisons can detect
75
- // which state keys changed.
110
+ // Build plain-object snapshots for comparison. Spreading
111
+ // instance.scopedState yields its local vars (item, index) plus
112
+ // whatever globals were captured at scope-creation time; the
113
+ // overlaying spread of state/newState then writes the *current*
114
+ // values from this update cycle. Both merged states are plain
115
+ // objects with up-to-date values.
76
116
  const mergedOldState = { ...instance.scopedState, ...state };
77
117
  const mergedNewState = { ...instance.scopedState, ...newState };
78
- // Pass scopedState as scopedStateForHydration so evalInScope can access iteration variables
79
- recursive(instance.tree, mergedOldState, mergedNewState, affected, instance.scopedState, depth + 1);
118
+ // scopedStateForHydration must reflect the new state so hydrate's
119
+ // bindings inside the row see post-update values. instance.scopedState
120
+ // is frozen against whatever target renderIteration was called with
121
+ // (often the plain initialState snapshot from index.js), so reading
122
+ // outer-state keys through it returns stale values after later
123
+ // mutations. mergedNewState carries the live globals + localVars.
124
+ recursive(
125
+ instance.tree,
126
+ mergedOldState,
127
+ mergedNewState,
128
+ affected,
129
+ mergedNewState,
130
+ depth + 1,
131
+ );
80
132
  }
81
133
  }
82
134
  }
@@ -85,23 +137,38 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
85
137
  }
86
138
 
87
139
  // Handle conditional nodes specially
88
- if (tree.type === 'conditional') {
89
- const oldValue = evaluateCondition(tree.meta.expression, state, tree.meta.startComment?.parentElement);
90
- const newValue = evaluateCondition(tree.meta.expression, newState, tree.meta.startComment?.parentElement);
140
+ if (tree.type === "conditional") {
141
+ const oldValue = evaluateCondition(
142
+ tree.meta.expression,
143
+ state,
144
+ tree.meta.startComment?.parentElement,
145
+ );
146
+ const newValue = evaluateCondition(
147
+ tree.meta.expression,
148
+ newState,
149
+ tree.meta.startComment?.parentElement,
150
+ );
91
151
 
92
152
  // Check if condition result changed
93
153
  if (oldValue !== newValue) {
94
154
  affected.push({
95
- type: 'conditional',
155
+ type: "conditional",
96
156
  node: tree,
97
- changeType: 'expression',
157
+ changeType: "expression",
98
158
  });
99
159
  return affected;
100
160
  }
101
161
 
102
162
  // Condition didn't change, check for affected elements inside active branch
103
163
  if (tree.runtime.activeInstance && tree.runtime.activeInstance.parsedTree) {
104
- return recursive(tree.runtime.activeInstance.parsedTree, state, newState, affected, scopedStateForHydration, depth + 1);
164
+ return recursive(
165
+ tree.runtime.activeInstance.parsedTree,
166
+ state,
167
+ newState,
168
+ affected,
169
+ scopedStateForHydration,
170
+ depth + 1,
171
+ );
105
172
  }
106
173
 
107
174
  return affected;
@@ -128,21 +195,29 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
128
195
  // Resolve this.property to componentId.property
129
196
  const resolvedInner = resolveThisPath(m.inner, tree.element);
130
197
 
131
- const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
198
+ const noMatch = !shallowState.some((key) =>
199
+ matchesKey(resolvedInner, key),
200
+ );
132
201
 
133
202
  let shouldAffect = false;
134
203
  let relevantKeys = [];
135
204
 
136
205
  if (isInitialHydration) {
137
206
  // Initial hydration: affect all matched keys
138
- relevantKeys = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
207
+ relevantKeys = shallowNewState.filter((key) =>
208
+ matchesKey(resolvedInner, key),
209
+ );
139
210
  shouldAffect = noMatch || relevantKeys.length > 0;
140
211
  } else {
141
212
  // Update: only affect if value changed
142
- const changedKeys = shallowNewState.filter((key) =>
143
- matchesKey(resolvedInner, key) && state[key] !== newState[key]
213
+ const changedKeys = shallowNewState.filter(
214
+ (key) =>
215
+ matchesKey(resolvedInner, key) && state[key] !== newState[key],
144
216
  );
145
- relevantKeys = changedKeys.length > 0 ? changedKeys : shallowState.filter((key) => matchesKey(resolvedInner, key));
217
+ relevantKeys =
218
+ changedKeys.length > 0
219
+ ? changedKeys
220
+ : shallowState.filter((key) => matchesKey(resolvedInner, key));
146
221
  shouldAffect = noMatch || changedKeys.length > 0;
147
222
  }
148
223
 
@@ -189,25 +264,30 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
189
264
  // Resolve this.property to componentId.property
190
265
  const resolvedInner = resolveThisPath(m.inner, tree.element);
191
266
 
192
- const noMatch = !shallowState.some((key) => matchesKey(resolvedInner, key));
267
+ const noMatch = !shallowState.some((key) =>
268
+ matchesKey(resolvedInner, key),
269
+ );
193
270
 
194
271
  let shouldAffect = false;
195
272
 
196
273
  if (isInitialHydration) {
197
274
  // Initial hydration: affect all matched keys
198
- const newMatches = shallowNewState.filter((key) => matchesKey(resolvedInner, key));
275
+ const newMatches = shallowNewState.filter((key) =>
276
+ matchesKey(resolvedInner, key),
277
+ );
199
278
  shouldAffect = noMatch || newMatches.length > 0;
200
279
  } else {
201
280
  // Update: only affect if value changed
202
- const changedKeys = shallowNewState.filter((key) =>
203
- matchesKey(resolvedInner, key) && state[key] !== newState[key]
281
+ const changedKeys = shallowNewState.filter(
282
+ (key) =>
283
+ matchesKey(resolvedInner, key) && state[key] !== newState[key],
204
284
  );
205
285
  shouldAffect = noMatch || changedKeys.length > 0;
206
286
  }
207
287
 
208
288
  if (shouldAffect) {
209
289
  affected.push({
210
- type: 'attribute',
290
+ type: "attribute",
211
291
  attrName,
212
292
  attrValue,
213
293
  matchOuter: m.outer,
@@ -239,7 +319,9 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
239
319
  const resolvedInner = resolveThisPath(m.inner, tree.element);
240
320
  // HTML lowercases attribute names, so expression may be lowercase while state
241
321
  // keys are camelCase. Match case-insensitively by trying both direct and lowercased.
242
- const matchKey = (key) => matchesKey(resolvedInner, key) || matchesKey(resolvedInner, key.toLowerCase());
322
+ const matchKey = (key) =>
323
+ matchesKey(resolvedInner, key) ||
324
+ matchesKey(resolvedInner, key.toLowerCase());
243
325
 
244
326
  const noMatch = !shallowState.some(matchKey);
245
327
 
@@ -249,15 +331,15 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
249
331
  const newMatches = shallowNewState.filter(matchKey);
250
332
  shouldAffect = noMatch || newMatches.length > 0;
251
333
  } else {
252
- const changedKeys = shallowNewState.filter((key) =>
253
- matchKey(key) && state[key] !== newState[key]
334
+ const changedKeys = shallowNewState.filter(
335
+ (key) => matchKey(key) && state[key] !== newState[key],
254
336
  );
255
337
  shouldAffect = noMatch || changedKeys.length > 0;
256
338
  }
257
339
 
258
340
  if (shouldAffect) {
259
341
  affected.push({
260
- type: 'nameBinding',
342
+ type: "nameBinding",
261
343
  nameBinding,
262
344
  matchOuter: m.outer,
263
345
  matchInner: m.inner, // Keep original, evalInScope will resolve this.
@@ -274,8 +356,15 @@ const recursive = (tree, state, newState, affected, scopedStateForHydration = nu
274
356
  if (children) {
275
357
  for (const key in children) {
276
358
  const child = children[key];
277
- if (child && typeof child === 'object') {
278
- recursive(child, state, newState, affected, scopedStateForHydration, depth + 1);
359
+ if (child && typeof child === "object") {
360
+ recursive(
361
+ child,
362
+ state,
363
+ newState,
364
+ affected,
365
+ scopedStateForHydration,
366
+ depth + 1,
367
+ );
279
368
  }
280
369
  }
281
370
  }
@@ -41,9 +41,49 @@ export const releaseOrphanedComponentState = (collectedIds) => {
41
41
  delete window.__vibeComponents?.[id];
42
42
  // CLEANUP OF CURRENT STATE
43
43
  delete window.$[id];
44
+ runComponentCleanups(id);
44
45
  }
45
46
  };
46
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
+
47
87
  // Walk a node subtree (element or node list) and collect all
48
88
  // `data-vibe-component-id` values found on the node and its descendants.
49
89
  export const collectComponentIds = (node, into = new Set()) => {
@@ -145,12 +185,18 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
145
185
  const DIRECTIVE_COMMENT_REGEX = /<!--\s*(if|else if|each)\s+([^]*?)\s*-->/g;
146
186
  const EVENT_ATTR_REGEX = /\bon(\w+)="([^"]*)"/g;
147
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`.
148
194
  Object.entries(props).forEach(([propName, propValue]) => {
149
195
  const bindingMatch = propValue.match(/^@\[(.+)\]$/);
150
- const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'g');
196
+ const exactPattern = new RegExp(`@\\[${escapeRegex(propName)}\\]`, 'gi');
151
197
  const idRegex = new RegExp(
152
198
  `(?<![a-zA-Z0-9_$\\.])${escapeRegex(propName)}(?![a-zA-Z0-9_$])`,
153
- 'g'
199
+ 'gi'
154
200
  );
155
201
 
156
202
  const substituteInExpr = (expr, replacement) => expr.replace(idRegex, replacement);
@@ -170,7 +216,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
170
216
  return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
171
217
  }
172
218
  );
173
- const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
219
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
174
220
  transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
175
221
  const rewritten = body.replace(stateRegex, `$.${path}`);
176
222
  return rewritten === body ? match : `on${evName}="${rewritten}"`;
@@ -196,7 +242,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
196
242
  return rewritten === expr ? match : `<!-- ${kw} ${rewritten} -->`;
197
243
  }
198
244
  );
199
- const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'g');
245
+ const stateRegex = new RegExp(`\\$\\.${escapeRegex(propName)}(?![a-zA-Z0-9_$])`, 'gi');
200
246
  transformedHtml = transformedHtml.replace(EVENT_ATTR_REGEX, (match, evName, body) => {
201
247
  const rewritten = body.replace(stateRegex, `$.${literal}`);
202
248
  return rewritten === body ? match : `on${evName}="${rewritten}"`;
@@ -281,6 +327,21 @@ const processSingle = (el, debug) => {
281
327
  // Process each script — collect async tasks if any have imports
282
328
  const asyncTasks = [];
283
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
+
284
345
  for (const script of moduleScripts) {
285
346
  let scriptContent = script.textContent?.trim() || '';
286
347
  if (!scriptContent) continue;
@@ -297,7 +358,11 @@ const processSingle = (el, debug) => {
297
358
 
298
359
  if (hasImports) {
299
360
  // Rewrite remaining imports to dynamic await import()
300
- // 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
+ );
301
366
  scriptContent = scriptContent.replace(
302
367
  /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
303
368
  'const $1 = (await import($2)).default;'
@@ -320,6 +385,8 @@ const processSingle = (el, debug) => {
320
385
  const reuseIds = el._vibeReuseComponentIds;
321
386
  const componentId = reuseIds && reuseIds.length ? reuseIds.shift() : generateComponentId();
322
387
 
388
+ if (firstComponentId === null) firstComponentId = componentId;
389
+
323
390
  // Provide a component() function that registers state for this component.
324
391
  // Sibling tagging is handled below (before script.remove()) so it works
325
392
  // for both sync and async scripts.
@@ -327,18 +394,40 @@ const processSingle = (el, debug) => {
327
394
  if (!window.__vibeComponents) window.__vibeComponents = {};
328
395
  window.__vibeComponents[componentId] = state;
329
396
  if (window.$) window.$[componentId] = state;
397
+ if (!registeredComponentIds.includes(componentId)) {
398
+ registeredComponentIds.push(componentId);
399
+ }
400
+ // Return the id so consumers can reach their reactive state via
401
+ // `$[id]` — matches the public component.js contract. Without this,
402
+ // `const id = component(state)` is undefined for src-fetched
403
+ // components and `$[id]` silently resolves to nothing.
404
+ return componentId;
330
405
  };
331
406
 
407
+ // Re-running the script for a reused componentId (HMR remount) must
408
+ // tear down listeners from the previous evaluation before the fresh
409
+ // script registers new ones. Without this, every cycle stacks another
410
+ // listener on top of the stale closures and a single reactive tick
411
+ // fires N callbacks instead of one.
412
+ runComponentCleanups(componentId);
413
+
414
+ // Provide a per-script `$` whose `.on(...)` registers cleanups under
415
+ // this componentId. Bare `$` in the script body resolves to this
416
+ // Proxy (the function parameter shadows the global), so listener
417
+ // registrations are auto-tracked across `await` boundaries via the
418
+ // closure — no opt-in required.
419
+ const scopedDollar = createScopedDollar(componentId);
420
+
332
421
  // Execute script with component() function in scope
333
422
  try {
334
423
  if (hasImports) {
335
424
  // Async execution for scripts with imports
336
425
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
337
- asyncTasks.push(new AsyncFunction('component', scriptContent)(componentFn));
426
+ asyncTasks.push(new AsyncFunction('$', 'component', scriptContent)(scopedDollar, componentFn));
338
427
  } else {
339
428
  // Synchronous execution for scripts without imports (preserves boot timing)
340
- const executeFn = new Function('component', scriptContent);
341
- executeFn(componentFn);
429
+ const executeFn = new Function('$', 'component', scriptContent);
430
+ executeFn(scopedDollar, componentFn);
342
431
  }
343
432
  } catch (e) {
344
433
  console.warn('[vibe] Failed to execute component script:', e);
@@ -374,6 +463,9 @@ const processSingle = (el, debug) => {
374
463
  }
375
464
 
376
465
  newWrapper.innerHTML = transformedHtml;
466
+ if (firstComponentId !== null) {
467
+ newWrapper.setAttribute('data-vibe-component-id', firstComponentId);
468
+ }
377
469
  // Stash the raw source so the HMR plugin can establish a baseline
378
470
  // script hash on the very first update — without this, the first
379
471
  // save after page load would always fall back to re-mount (since
@@ -392,12 +484,37 @@ const processSingle = (el, debug) => {
392
484
  el._vibeIterPropIds = null;
393
485
  el.removeAttribute('data-vibe-iter-prop');
394
486
  }
487
+ // Transfer the original prop expressions too, so the iteration's
488
+ // update path can re-evaluate them against the row's new scope and
489
+ // refresh the registry slots in place — letting the inlined
490
+ // component's bindings react without rebuilding the row's DOM.
491
+ if (el._vibeIterPropExprs) {
492
+ newWrapper._vibeIterPropExprs = el._vibeIterPropExprs;
493
+ el._vibeIterPropExprs = null;
494
+ }
495
+ // Back-pointer from the soon-to-be-detached `<component src>` to
496
+ // the new wrapper. The iteration's `instance.clonedNodes` still
497
+ // references the original element; follow this link to reach the
498
+ // live wrapper when refreshing registry slots / re-hydrating.
499
+ // The wrapper's `_vibeIterTree` (set later by processMutations after
500
+ // renderAllConditionals/Iterations populated runtime data) is what
501
+ // iterate.js's update path uses to re-evaluate inlined bindings on
502
+ // each row update.
503
+ el._vibeReplacedBy = newWrapper;
395
504
  el.replaceWith(newWrapper);
396
505
  debugLog(PHASE_FETCH, src, debug);
397
506
 
398
507
  // MutationObserver handles parsing and hydrating the new content.
399
508
  // Branch nodes are registered in the manifest by mountBranch,
400
509
  // so the observer can find parents even inside conditional branches.
510
+ } else {
511
+ // Element was detached before finalize ran (conditional unmounted
512
+ // during fetch, parent removed, etc). Release any state component()
513
+ // calls registered — otherwise it leaks on `$` forever.
514
+ for (const id of registeredComponentIds) {
515
+ delete window.__vibeComponents?.[id];
516
+ if (window.$) delete window.$[id];
517
+ }
401
518
  }
402
519
  };
403
520
 
@@ -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+/;