@ape-egg/vibe 1.9.9 → 2.0.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,10 @@
1
1
  import { debugLog } from './debug.js';
2
- import { PHASE_READY, FOUC_CLASS_OR_ATTR, DEHYDRATE_CLASS_OR_ATTR } from './constants.js';
2
+ import {
3
+ PHASE_READY,
4
+ FOUC_CLASS_OR_ATTR,
5
+ DEHYDRATE_CLASS_OR_ATTR,
6
+ NON_REACTIVE_ELEMENTS,
7
+ } from './constants.js';
3
8
 
4
9
  /**
5
10
  * Check if all Vibe processing is complete and cleanup can run
@@ -14,12 +19,16 @@ export const shouldCleanup = (rootElement) => {
14
19
  return false;
15
20
  }
16
21
 
17
- // 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated elements)
22
+ // 2. Check for unhydrated bindings (literal @[...] in DOM, excluding dehydrated
23
+ // and non-reactive elements — hydrate never touches those, so a literal inside
24
+ // them is final content, not pending work)
18
25
  const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, {
19
26
  acceptNode(node) {
20
- // Check if this text node is inside a dehydrated element
21
27
  let parent = node.parentElement;
22
28
  while (parent && parent !== rootElement) {
29
+ if (NON_REACTIVE_ELEMENTS.includes(parent.nodeName)) {
30
+ return NodeFilter.FILTER_REJECT;
31
+ }
23
32
  if (parent.hasAttribute(DEHYDRATE_CLASS_OR_ATTR) || parent.classList?.contains(DEHYDRATE_CLASS_OR_ATTR)) {
24
33
  return NodeFilter.FILTER_REJECT; // Skip dehydrated content
25
34
  }
@@ -98,9 +98,131 @@ export const collectComponentIds = (node, into = new Set()) => {
98
98
  return into;
99
99
  };
100
100
 
101
+ // Prepare a component <script> body for execution through the injected
102
+ // component() path: strip the `import component from '...'` line (the
103
+ // function is passed in as a parameter) and rewrite any remaining static
104
+ // imports to awaited dynamic imports. Shared by the fetch path
105
+ // (processSingle) and the compiled-page path (executeCompiledComponentScripts).
106
+ const transformScriptContent = (rawContent) => {
107
+ // Strip `import component from '...'` — Vibe injects the contextual
108
+ // component() function as a parameter (it needs access to the temp DOM)
109
+ let content = rawContent.replace(
110
+ /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
111
+ ''
112
+ );
113
+
114
+ // Check for remaining imports that need rewriting
115
+ const hasImports = /import\s/.test(content);
116
+
117
+ if (hasImports) {
118
+ // Rewrite remaining imports to dynamic await import()
119
+ // Order matters: combined → default → named → namespace → side-effect (most specific first)
120
+ content = content.replace(
121
+ /import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
122
+ 'const __m_$1 = await import($3); const $1 = __m_$1.default; const {$2} = __m_$1;'
123
+ );
124
+ content = content.replace(
125
+ /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
126
+ 'const $1 = (await import($2)).default;'
127
+ );
128
+ content = content.replace(
129
+ /import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
130
+ 'const {$1} = await import($2);'
131
+ );
132
+ content = content.replace(
133
+ /import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
134
+ 'const $1 = await import($2);'
135
+ );
136
+ content = content.replace(
137
+ /import\s+(['"][^'"]+['"])\s*;?/g,
138
+ 'await import($1);'
139
+ );
140
+ }
141
+
142
+ return { content, hasImports };
143
+ };
144
+
145
+ // Compiled pages inline `<component src>` content at build time, and the
146
+ // compiler neuters each component script to type="vibe-module" so the browser
147
+ // does NOT execute it as a native page module — native timing is wrong
148
+ // (pre-boot: `$` is the placeholder, state registered after boot never merges
149
+ // into the live proxy). This executes those scripts through the same
150
+ // injected-component() path processSingle uses for fetched scripts: one
151
+ // pipeline, identical semantics in both modes. Scripts stay in the DOM
152
+ // (inert) so the manifest's childNodes indices keep matching the page.
153
+ //
154
+ // Returns a Promise when any script is async (has imports) — the caller
155
+ // gates `ready` on it — or null when everything ran synchronously.
156
+ export const executeCompiledComponentScripts = () => {
157
+ const scripts = document.querySelectorAll('script[type="vibe-module"]');
158
+ if (!scripts.length) return null;
159
+
160
+ // Build-time tagging already assigned _cN ids to wrappers; advance the
161
+ // runtime counter past them so freshly generated ids never collide.
162
+ document.querySelectorAll('[data-vibe-component-id]').forEach((el) => {
163
+ const m = el.getAttribute('data-vibe-component-id')?.match(/^_c(\d+)$/);
164
+ if (m) componentCounter = Math.max(componentCounter, Number(m[1]) + 1);
165
+ });
166
+
167
+ const asyncTasks = [];
168
+ const claimed = new Set();
169
+
170
+ for (const script of scripts) {
171
+ // Parity with the fetch path: dehydrated components never execute
172
+ if (script.closest(`[${DEHYDRATE_CLASS_OR_ATTR}], .${DEHYDRATE_CLASS_OR_ATTR}`)) continue;
173
+
174
+ const rawContent = script.textContent?.trim() || '';
175
+ if (!rawContent) continue;
176
+
177
+ // The build tagged each component wrapper with its deterministic id —
178
+ // the same id the stamped bindings reference. First script in a wrapper
179
+ // claims it; additional scripts get fresh ids (mirrors the per-script
180
+ // ids of the fetch path).
181
+ const wrapper = script.closest('[data-vibe-component-id]');
182
+ let componentId = wrapper?.getAttribute('data-vibe-component-id');
183
+ if (!componentId || claimed.has(componentId)) componentId = generateComponentId();
184
+ claimed.add(componentId);
185
+
186
+ const { content, hasImports } = transformScriptContent(rawContent);
187
+
188
+ const componentFn = (state) => {
189
+ if (!window.__vibeComponents) window.__vibeComponents = {};
190
+ window.__vibeComponents[componentId] = state;
191
+ if (window.$) window.$[componentId] = state;
192
+ return componentId;
193
+ };
194
+
195
+ runComponentCleanups(componentId);
196
+ const scopedDollar = createScopedDollar(componentId);
197
+
198
+ try {
199
+ if (hasImports) {
200
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
201
+ asyncTasks.push(new AsyncFunction('$', 'component', content)(scopedDollar, componentFn));
202
+ } else {
203
+ new Function('$', 'component', content)(scopedDollar, componentFn);
204
+ }
205
+ } catch (e) {
206
+ console.warn('[vibe] Failed to execute component script:', e);
207
+ }
208
+ }
209
+
210
+ return asyncTasks.length ? Promise.all(asyncTasks) : null;
211
+ };
212
+
101
213
  // Track pending fetches to cancel them if element is removed
102
214
  const pendingFetches = new WeakMap(); // element → AbortController
103
215
 
216
+ // Component HTML is parsed (and prop/slot-transformed) while its @[...]
217
+ // bindings are still literal text. Parsing in the live document lets the
218
+ // browser act on those literals mid-parse — Chrome logs "The specified value
219
+ // ... cannot be parsed" for a typed input's value="@[...]". An inert document
220
+ // (no browsing context) parses identical DOM without a console to complain to;
221
+ // nodes are auto-adopted into the live document on insertion.
222
+ let inertDocument;
223
+ const createDetached = (tagName) =>
224
+ (inertDocument ??= document.implementation.createHTMLDocument('')).createElement(tagName);
225
+
104
226
  // Cancel a pending fetch for a component element
105
227
  export const abortComponentFetch = (element) => {
106
228
  const controller = pendingFetches.get(element);
@@ -273,7 +395,7 @@ const renderPropsAndSlot = (temp, props, slotHtml) => {
273
395
  // or trigger a full re-mount (new componentIds).
274
396
  export const renderComponentTemplate = (rawHtml, props = {}, slotHtml = '', options = {}) => {
275
397
  const { componentIds = [] } = options;
276
- const temp = document.createElement('div');
398
+ const temp = createDetached('div');
277
399
  temp.innerHTML = rawHtml;
278
400
 
279
401
  const idsToReuse = [...componentIds];
@@ -318,7 +440,7 @@ const processSingle = (el, debug) => {
318
440
  .then((r) => r.text())
319
441
  .then((html) => {
320
442
  // Parse HTML in temporary container to process component scripts
321
- const temp = document.createElement('div');
443
+ const temp = createDetached('div');
322
444
  temp.innerHTML = html;
323
445
 
324
446
  // Process any <script type="module"> elements
@@ -343,43 +465,10 @@ const processSingle = (el, debug) => {
343
465
  let firstComponentId = null;
344
466
 
345
467
  for (const script of moduleScripts) {
346
- let scriptContent = script.textContent?.trim() || '';
347
- if (!scriptContent) continue;
348
-
349
- // Strip `import component from '...'` Vibe injects the contextual
350
- // component() function as a parameter (it needs access to the temp DOM)
351
- scriptContent = scriptContent.replace(
352
- /import\s+component\s+from\s+['"][^'"]+['"];?\s*/g,
353
- ''
354
- );
355
-
356
- // Check for remaining imports that need rewriting
357
- const hasImports = /import\s/.test(scriptContent);
358
-
359
- if (hasImports) {
360
- // Rewrite remaining imports to dynamic await import()
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
- );
366
- scriptContent = scriptContent.replace(
367
- /import\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
368
- 'const $1 = (await import($2)).default;'
369
- );
370
- scriptContent = scriptContent.replace(
371
- /import\s+\{([^}]+)\}\s+from\s+(['"][^'"]+['"])\s*;?/g,
372
- 'const {$1} = await import($2);'
373
- );
374
- scriptContent = scriptContent.replace(
375
- /import\s+\*\s+as\s+(\w+)\s+from\s+(['"][^'"]+['"])\s*;?/g,
376
- 'const $1 = await import($2);'
377
- );
378
- scriptContent = scriptContent.replace(
379
- /import\s+(['"][^'"]+['"])\s*;?/g,
380
- 'await import($1);'
381
- );
382
- }
468
+ const rawContent = script.textContent?.trim() || '';
469
+ if (!rawContent) continue;
470
+
471
+ const { content: scriptContent, hasImports } = transformScriptContent(rawContent);
383
472
 
384
473
  // Reuse component ID from HMR if available, otherwise generate new
385
474
  const reuseIds = el._vibeReuseComponentIds;
@@ -454,9 +543,7 @@ const processSingle = (el, debug) => {
454
543
  if (el.parentNode) {
455
544
  // Create clean wrapper element (preserve tag type: component or div.component)
456
545
  const newWrapper =
457
- el.tagName === 'DIV'
458
- ? document.createElement('div')
459
- : document.createElement('component');
546
+ el.tagName === 'DIV' ? createDetached('div') : createDetached('component');
460
547
 
461
548
  if (el.tagName === 'DIV') {
462
549
  newWrapper.className = 'component';
@@ -476,7 +563,7 @@ const processSingle = (el, debug) => {
476
563
  // detached `<component src>` to the new wrapper. The detached element
477
564
  // would otherwise trigger releaseOrphanedIterationProps and free the
478
565
  // registry slots that the inlined template's bindings still reference,
479
- // causing every `@[window.__vibeIterProps._pN]` to resolve to undefined
566
+ // causing every `@[window.__vibeiterprops._pN]` to resolve to undefined
480
567
  // on the next hydrate.
481
568
  if (el._vibeIterPropIds) {
482
569
  newWrapper._vibeIterPropIds = el._vibeIterPropIds;
@@ -263,18 +263,25 @@ const unmountBranch = (node, manifest) => {
263
263
  }
264
264
  }
265
265
 
266
- // Collect componentIds from the subtree BEFORE detaching so we can check
266
+ // The conditional owns everything between its comments. That's more than
267
+ // activeInstance.nodes: nested directives at the branch's top level (each
268
+ // rows, deeper if branches) insert their rendered output between their own
269
+ // comments after the branch mounts, so it never appears in the original
270
+ // clonedNodes list. Sweep the live range (same pattern as iteration
271
+ // teardown), collecting componentIds BEFORE detaching so we can check
267
272
  // after removal whether any live DOM still holds them.
273
+ const { startComment, endComment } = node.meta;
268
274
  const ids = new Set();
269
- activeInstance.nodes.forEach((domNode) => collectComponentIds(domNode, ids));
270
-
271
- // Remove all nodes from DOM and deregister from branch registry
272
- activeInstance.nodes.forEach((domNode) => {
273
- branchNodeRegistry.delete(domNode);
274
- if (domNode.parentNode) {
275
- domNode.parentNode.removeChild(domNode);
275
+ let current = startComment.nextSibling;
276
+ while (current && current !== endComment) {
277
+ const next = current.nextSibling;
278
+ collectComponentIds(current, ids);
279
+ branchNodeRegistry.delete(current);
280
+ if (current.parentNode) {
281
+ current.parentNode.removeChild(current);
276
282
  }
277
- });
283
+ current = next;
284
+ }
278
285
 
279
286
  // CLEANUP OF CURRENT STATE
280
287
  releaseOrphanedComponentState(ids);
@@ -92,9 +92,18 @@ export default (affected, state, manifest = {}, oldState = {}) => {
92
92
  const expr = isPureBinding[1];
93
93
  const value = evalInScope(expr, effectiveState, element);
94
94
  if (element[attrName] !== value) element[attrName] = value;
95
- if (value !== undefined && value !== null) {
96
- const str = String(value);
97
- if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
95
+ if (attrName === 'value') {
96
+ if (value !== undefined && value !== null) {
97
+ const str = String(value);
98
+ if (element.getAttribute(attrName) !== str) element.setAttribute(attrName, str);
99
+ }
100
+ } else if (value) {
101
+ // checked/selected are boolean — the truthful attribute form is
102
+ // presence (empty) when truthy, absence when falsy. Stringifying
103
+ // would leave checked="false", which is "checked" to CSS and HTML.
104
+ if (element.getAttribute(attrName) !== '') element.setAttribute(attrName, '');
105
+ } else if (element.hasAttribute(attrName)) {
106
+ element.removeAttribute(attrName);
98
107
  }
99
108
  } else if (!isValueAttr && isPureBinding) {
100
109
  // Boolean-like attributes: add or remove based on truthiness.
package/runtime/index.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  PHASE_READY,
24
24
  DEHYDRATE_CLASS_OR_ATTR,
25
25
  } from './constants.js';
26
- import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate } from './component.js';
26
+ import { processComponent, abortComponentFetch, collectComponentIds, releaseOrphanedComponentState, renderComponentTemplate, executeCompiledComponentScripts } from './component.js';
27
27
  import { debugLog } from './debug.js';
28
28
  import { shouldCleanup, cleanup } from './cleanup.js';
29
29
  import { reconcile } from './reconcile.js';
@@ -706,6 +706,18 @@ const main = (s, config = {}, stringSelector = '') => {
706
706
  // first conditional/binding eval.
707
707
  if (typeof window !== 'undefined') window.$ = $;
708
708
 
709
+ // Compiled pages: execute build-inlined component scripts (neutered to
710
+ // type="vibe-module" by the compiler) through the runtime's component-script
711
+ // pipeline — same injected component(), same scoped `$`, same import
712
+ // rewriting as fetched scripts. Runs after `window.$` is live so
713
+ // `const id = component(state); $[id].x = ...` captures the reactive proxy,
714
+ // and before initial hydration so synchronous scripts' state is already
715
+ // registered when `this.` bindings first evaluate. Async scripts gate
716
+ // `ready` via compiledScriptsDone below.
717
+ let compiledScriptsDone = true;
718
+ const compiledScriptsPending = executeCompiledComponentScripts();
719
+ if (compiledScriptsPending) compiledScriptsDone = false;
720
+
709
721
  // Initial hydration - pass plain values so iteration can do reference comparison
710
722
  const initialState = extractPlainValue($);
711
723
  const affectedElements = affected(parsedTree, initialState, initialState);
@@ -1125,7 +1137,7 @@ const main = (s, config = {}, stringSelector = '') => {
1125
1137
 
1126
1138
  // Check if cleanup should run
1127
1139
  const checkCleanup = () => {
1128
- if (cleanupExecuted || isCompiling) return;
1140
+ if (cleanupExecuted || isCompiling || !compiledScriptsDone) return;
1129
1141
 
1130
1142
  // Check for pending mutations first
1131
1143
  const pendingMutations = observer ? observer.takeRecords() : [];
@@ -1157,6 +1169,15 @@ const main = (s, config = {}, stringSelector = '') => {
1157
1169
  // Register hook to check for cleanup readiness after each mutation batch
1158
1170
  hooks.afterDomMutation.push(checkCleanup);
1159
1171
 
1172
+ // Async compiled component scripts (imports) finish after boot — unlock the
1173
+ // ready gate and re-check once their state has merged into `$`.
1174
+ if (compiledScriptsPending) {
1175
+ compiledScriptsPending.then(() => {
1176
+ compiledScriptsDone = true;
1177
+ checkCleanup();
1178
+ });
1179
+ }
1180
+
1160
1181
  // Process component elements after initialization - MutationObserver will handle hydration
1161
1182
  componentProcessingStarted = true;
1162
1183
 
@@ -87,6 +87,40 @@ const BATCH_ATTR_BINDING_REGEX = new RegExp(
87
87
  'g',
88
88
  );
89
89
 
90
+ // Apply fn to tag spans only (`<el ...>`, quote-aware so a `>` inside an
91
+ // attribute value doesn't end the span), leaving text spans and comments
92
+ // untouched. Used to scope the name-binding rewrite to positions where a
93
+ // name binding can actually occur.
94
+ const mapTagSpans = (html, fn) => {
95
+ let out = '';
96
+ let i = 0;
97
+ while (i < html.length) {
98
+ const lt = html.indexOf('<', i);
99
+ if (lt === -1) {
100
+ out += html.slice(i);
101
+ break;
102
+ }
103
+ out += html.slice(i, lt);
104
+ let j = lt + 1;
105
+ let quote = null;
106
+ while (j < html.length) {
107
+ const c = html[j];
108
+ if (quote) {
109
+ if (c === quote) quote = null;
110
+ } else if (c === '"' || c === "'") {
111
+ quote = c;
112
+ } else if (c === '>') {
113
+ break;
114
+ }
115
+ j++;
116
+ }
117
+ const span = html.slice(lt, Math.min(j + 1, html.length));
118
+ out += /^<\/?[a-zA-Z]/.test(span) ? fn(span) : span;
119
+ i = j + 1;
120
+ }
121
+ return out;
122
+ };
123
+
90
124
  // Whether a hydrate'd attribute is a "value-style" string attr (kept verbatim)
91
125
  // rather than a boolean-coerced attr (added/removed by truthiness). Mirrors
92
126
  // the predicate hydrate.js uses, so batch and clone classify identically.
@@ -126,6 +160,17 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
126
160
  const componentId = anchorEl ? findComponentIdForElement(anchorEl) : null;
127
161
  const domPropertyWrites = [];
128
162
 
163
+ // Trim binding-bearing text nodes — the clone path writes the interpolated
164
+ // text node content trimmed (hydrate.js), so the batch template must not
165
+ // carry the author's indentation around a binding that sits on its own line.
166
+ const textWalker = document.createTreeWalker(tplClone, NodeFilter.SHOW_TEXT);
167
+ while (textWalker.nextNode()) {
168
+ const textNode = textWalker.currentNode;
169
+ if (textNode.textContent.includes('@[')) {
170
+ textNode.textContent = textNode.textContent.trim();
171
+ }
172
+ }
173
+
129
174
  const allEls = tplClone.querySelectorAll('*');
130
175
  for (let n = 0; n < allEls.length; n++) {
131
176
  const el = allEls[n];
@@ -167,7 +212,10 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
167
212
 
168
213
  // Name bindings: `<el @[expr]>` — emit ` resolvedName=""` when truthy, else
169
214
  // emit nothing. The lookahead `(?=[\s/>])` distinguishes name-position
170
- // bindings from attribute-value-position bindings (which are followed by `=`).
215
+ // bindings from attribute-value-position bindings (which are followed by `=`)
216
+ // — but a TEXT-position binding on its own line is also whitespace-bounded,
217
+ // so the pass runs only over tag spans (mapTagSpans): name bindings can only
218
+ // exist inside a tag.
171
219
  //
172
220
  // HTML parses attribute names lowercase, so a binding like
173
221
  // `<icon @[attrName]>` arrives here as `@[attrname]` and a dotted form like
@@ -182,7 +230,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
182
230
  // Bracket / call expressions pass through unchanged — they need a real
183
231
  // evaluator and aren't worth special-casing here.
184
232
  let needsCiWalker = false;
185
- code = code.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
233
+ code = mapTagSpans(code, (tag) => tag.replace(BATCH_NAME_BINDING_REGEX, (_, _ws, expr) => {
186
234
  let decExpr = decodeEntities(expr);
187
235
  if (decExpr.includes('[') || decExpr.includes('(')) {
188
236
  return '${(' + decExpr + ') ? \' \' + (' + decExpr + ') + \'=""\' : \'\'}';
@@ -203,7 +251,7 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
203
251
  '${(()=>{const _v=_walkCi(' + ciHead + ',' + tailJSON +
204
252
  ');return _v?\' \'+_v+\'=""\':\'\';})()}'
205
253
  );
206
- });
254
+ }));
207
255
 
208
256
  // Pure-binding attributes (`attr="@[expr]"`) — classify by attribute name:
209
257
  // DOM property → emit attribute (post-stamp also writes the property)
@@ -266,23 +314,31 @@ const compileBatchFn = (template, itemAlias, indexAlias, stateKeys, anchorEl) =>
266
314
  // reachable from binding expressions because `window` is in evalInScope's
267
315
  // known-globals list. Each entry is freed when the owning component element
268
316
  // is detached (see releaseOrphanedIterationProps).
317
+ //
318
+ // The name is intentionally all-lowercase. resolveIterationComponentProps
319
+ // injects `@[window.__vibeiterprops._pN]` into the component's bindings, and
320
+ // prop substitution carries that accessor into the template's own bindings —
321
+ // including name-bindings (`<icon @[props.element]>`). HTML lowercases
322
+ // attribute names, so a camelCase accessor would arrive at hydrate as
323
+ // `window.__vibeiterprops` and resolve to undefined, silently dropping the
324
+ // attribute. Keeping the global lowercase makes it survive that normalization.
269
325
  let __vibeIterPropCounter = 0;
270
326
  const ensureIterPropsRegistry = () => {
271
- if (!window.__vibeIterProps) window.__vibeIterProps = {};
272
- return window.__vibeIterProps;
327
+ if (!window.__vibeiterprops) window.__vibeiterprops = {};
328
+ return window.__vibeiterprops;
273
329
  };
274
330
 
275
331
  // Walk a removed subtree and free any iteration-prop registry slots stashed
276
332
  // on `<component>` elements inside it. Called from the mutation-observer
277
333
  // cleanup path after DOM detachment.
278
334
  export const releaseOrphanedIterationProps = (nodes) => {
279
- if (!window.__vibeIterProps) return;
335
+ if (!window.__vibeiterprops) return;
280
336
  for (const node of nodes) {
281
337
  if (node.nodeType !== 1) continue;
282
338
  const free = (el) => {
283
339
  const ids = el._vibeIterPropIds;
284
340
  if (!ids) return;
285
- for (const id of ids) delete window.__vibeIterProps[id];
341
+ for (const id of ids) delete window.__vibeiterprops[id];
286
342
  el._vibeIterPropIds = null;
287
343
  };
288
344
  free(node);
@@ -293,7 +349,7 @@ export const releaseOrphanedIterationProps = (nodes) => {
293
349
  // For <component src> elements inside an iteration instance, evaluate any
294
350
  // `@[expr]` attribute bindings against the iteration's scoped state and route
295
351
  // every resolved value through the global iteration-prop registry. The prop
296
- // attribute becomes `@[window.__vibeIterProps._pN]` — a live binding into the
352
+ // attribute becomes `@[window.__vibeiterprops._pN]` — a live binding into the
297
353
  // registry slot — for both primitives and objects. The original expression is
298
354
  // stashed on the element so the iteration's update path can re-evaluate it
299
355
  // against the new scope and refresh the slot, propagating the change into the
@@ -327,7 +383,7 @@ export const resolveIterationComponentProps = (nodes, scopedState) => {
327
383
  const registry = ensureIterPropsRegistry();
328
384
  const id = `_p${__vibeIterPropCounter++}`;
329
385
  registry[id] = value;
330
- el.setAttribute(attr.name, `@[window.__vibeIterProps.${id}]`);
386
+ el.setAttribute(attr.name, `@[window.__vibeiterprops.${id}]`);
331
387
  el.setAttribute('data-vibe-iter-prop', '');
332
388
  (el._vibeIterPropIds = el._vibeIterPropIds || []).push(id);
333
389
  (el._vibeIterPropExprs = el._vibeIterPropExprs || []).push({ id, attrName: attr.name, expr });
@@ -388,6 +444,15 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
388
444
  for (const { id, expr } of el._vibeIterPropExprs) {
389
445
  try {
390
446
  const value = evalInScope(expr, scopedState, el);
447
+ // Don't clobber a slot with undefined — mirrors the mount-time guard in
448
+ // resolveIterationComponentProps. forEachIterWrapper reaches every
449
+ // [data-vibe-iter-prop] descendant, including components owned by a
450
+ // DEEPER iteration (e.g. a cell component inside a nested each). Their
451
+ // prop expressions reference the inner each's alias, which isn't in this
452
+ // (outer) row's scope, so they evaluate to undefined here. Skipping keeps
453
+ // the value the inner iteration's own update already set with the correct
454
+ // scope, instead of wiping it to undefined and leaving raw @[...] bindings.
455
+ if (value === undefined) continue;
391
456
  if (registry[id] !== value) {
392
457
  registry[id] = value;
393
458
  changed.add(id);
@@ -403,7 +468,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
403
468
 
404
469
  // Walk an inlined component's parsed tree and force `updateIteration` on any
405
470
  // iteration node whose arrayPath resolves through the iteration-prop registry
406
- // (`window.__vibeIterProps._pN`). The registry slot was just refreshed in
471
+ // (`window.__vibeiterprops._pN`). The registry slot was just refreshed in
407
472
  // place by `refreshIterationComponentProps`, so `affected()` can't notice
408
473
  // the change — both old/new evaluations of the path read the same updated
409
474
  // value. `updateIteration` is the only place equipped to diff against
@@ -412,7 +477,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
412
477
  // in sync. Without this, an `<inner-component>` whose template iterates over
413
478
  // an array prop stays frozen on its initial-render items when the prop's
414
479
  // contents change.
415
- const REGISTRY_SLOT_REGEX = /__vibeIterProps\.(_p\d+)/;
480
+ const REGISTRY_SLOT_REGEX = /__vibeiterprops\.(_p\d+)/;
416
481
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
417
482
  if (!tree) return;
418
483
  if (tree.type === 'iteration') {
@@ -474,11 +539,19 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
474
539
  const { prop, expr } = domPropertyWrites[indexes[k] | 0];
475
540
  const value = evalInScope(expr, localState, el);
476
541
  if (el[prop] !== value) el[prop] = value;
477
- if (value === undefined || value === null) {
478
- if (el.hasAttribute(prop)) el.removeAttribute(prop);
479
- } else {
480
- const str = String(value);
481
- if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
542
+ if (prop === 'value') {
543
+ if (value === undefined || value === null) {
544
+ if (el.hasAttribute(prop)) el.removeAttribute(prop);
545
+ } else {
546
+ const str = String(value);
547
+ if (el.getAttribute(prop) !== str) el.setAttribute(prop, str);
548
+ }
549
+ } else if (value) {
550
+ // checked/selected are boolean — presence/absence is the truthful
551
+ // attribute form, matching the clone path in hydrate.js.
552
+ if (el.getAttribute(prop) !== '') el.setAttribute(prop, '');
553
+ } else if (el.hasAttribute(prop)) {
554
+ el.removeAttribute(prop);
482
555
  }
483
556
  }
484
557
  el.removeAttribute('data-vibe-batch');
@@ -974,7 +1047,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
974
1047
  // mutation was a registry slot rewrite. The previously rendered
975
1048
  // items are the only honest record of what was there before.
976
1049
  // 2. stateOldArray === newArray — the iteration's arrayPath resolves
977
- // directly to a registry slot (`window.__vibeIterProps._pN`); the
1050
+ // directly to a registry slot (`window.__vibeiterprops._pN`); the
978
1051
  // slot was swapped in place, so both reads return the same NEW
979
1052
  // array.
980
1053
  // 3. length mismatch — oldState predates the current render.
@@ -1030,14 +1103,31 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1030
1103
  // actually depend on what changed. Before tearing down and recreating every
1031
1104
  // row, re-run the batch: if it yields identical HTML, the rows don't depend
1032
1105
  // on what changed, so keep the existing DOM nodes — preserving their event
1033
- // listeners (e.g. tooltip mouseleave) and any in-progress drag. Skip the
1034
- // shortcut when the template has DOM-property writes (value/checked/etc.),
1035
- // which aren't reflected in the HTML string.
1106
+ // listeners (e.g. tooltip mouseleave) and any in-progress click on a row
1107
+ // control. DOM-property writes (value/checked/etc.) aren't reflected in the
1108
+ // HTML string, so they're re-applied against the kept rows — property
1109
+ // assignment also wins over a user-dirtied checkbox, which an attribute
1110
+ // rewrite wouldn't.
1036
1111
  const rt = iterationNode.runtime;
1037
- if (rt.batchFn && rt.lastBatchHtml !== undefined && (!rt.domPropertyWrites || rt.domPropertyWrites.length === 0)) {
1112
+ if (rt.batchFn && rt.lastBatchHtml !== undefined) {
1038
1113
  const stateValues = rt.stateKeys.map((k) => newState[k]);
1039
1114
  const newHtml = rt.batchFn(newArray, ...stateValues, newState);
1040
- if (newHtml === rt.lastBatchHtml) return;
1115
+ if (newHtml === rt.lastBatchHtml) {
1116
+ if (rt.domPropertyWrites?.length) {
1117
+ // Identical HTML implies identical row count — refresh item refs so
1118
+ // $scope handlers and property writes read the live array.
1119
+ for (let i = 0; i < instances.length; i++) instances[i].item = newArray[i];
1120
+ applyDomPropertyWrites(
1121
+ instances,
1122
+ newArray,
1123
+ newState,
1124
+ iterationNode.meta.itemAlias,
1125
+ iterationNode.meta.indexAlias,
1126
+ rt.domPropertyWrites,
1127
+ );
1128
+ }
1129
+ return;
1130
+ }
1041
1131
  }
1042
1132
  bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1043
1133
  return;