@ape-egg/vibe 2.0.0 → 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.
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 });
@@ -412,7 +468,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
412
468
 
413
469
  // Walk an inlined component's parsed tree and force `updateIteration` on any
414
470
  // iteration node whose arrayPath resolves through the iteration-prop registry
415
- // (`window.__vibeIterProps._pN`). The registry slot was just refreshed in
471
+ // (`window.__vibeiterprops._pN`). The registry slot was just refreshed in
416
472
  // place by `refreshIterationComponentProps`, so `affected()` can't notice
417
473
  // the change — both old/new evaluations of the path read the same updated
418
474
  // value. `updateIteration` is the only place equipped to diff against
@@ -421,7 +477,7 @@ const refreshIterationComponentProps = (clonedNodes, scopedState) => {
421
477
  // in sync. Without this, an `<inner-component>` whose template iterates over
422
478
  // an array prop stays frozen on its initial-render items when the prop's
423
479
  // contents change.
424
- const REGISTRY_SLOT_REGEX = /__vibeIterProps\.(_p\d+)/;
480
+ const REGISTRY_SLOT_REGEX = /__vibeiterprops\.(_p\d+)/;
425
481
  const forceRegistryBackedIterationUpdates = (tree, state, manifest, parentScope, changedSlots) => {
426
482
  if (!tree) return;
427
483
  if (tree.type === 'iteration') {
@@ -483,11 +539,19 @@ const applyDomPropertyWrites = (instances, array, state, itemAlias, indexAlias,
483
539
  const { prop, expr } = domPropertyWrites[indexes[k] | 0];
484
540
  const value = evalInScope(expr, localState, el);
485
541
  if (el[prop] !== value) el[prop] = value;
486
- if (value === undefined || value === null) {
487
- if (el.hasAttribute(prop)) el.removeAttribute(prop);
488
- } else {
489
- const str = String(value);
490
- 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);
491
555
  }
492
556
  }
493
557
  el.removeAttribute('data-vibe-batch');
@@ -983,7 +1047,7 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
983
1047
  // mutation was a registry slot rewrite. The previously rendered
984
1048
  // items are the only honest record of what was there before.
985
1049
  // 2. stateOldArray === newArray — the iteration's arrayPath resolves
986
- // directly to a registry slot (`window.__vibeIterProps._pN`); the
1050
+ // directly to a registry slot (`window.__vibeiterprops._pN`); the
987
1051
  // slot was swapped in place, so both reads return the same NEW
988
1052
  // array.
989
1053
  // 3. length mismatch — oldState predates the current render.
@@ -1039,14 +1103,31 @@ export const updateIteration = (iterationNode, newState, oldState, manifest, par
1039
1103
  // actually depend on what changed. Before tearing down and recreating every
1040
1104
  // row, re-run the batch: if it yields identical HTML, the rows don't depend
1041
1105
  // on what changed, so keep the existing DOM nodes — preserving their event
1042
- // listeners (e.g. tooltip mouseleave) and any in-progress drag. Skip the
1043
- // shortcut when the template has DOM-property writes (value/checked/etc.),
1044
- // 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.
1045
1111
  const rt = iterationNode.runtime;
1046
- if (rt.batchFn && rt.lastBatchHtml !== undefined && (!rt.domPropertyWrites || rt.domPropertyWrites.length === 0)) {
1112
+ if (rt.batchFn && rt.lastBatchHtml !== undefined) {
1047
1113
  const stateValues = rt.stateKeys.map((k) => newState[k]);
1048
1114
  const newHtml = rt.batchFn(newArray, ...stateValues, newState);
1049
- 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
+ }
1050
1131
  }
1051
1132
  bulkReplace(iterationNode, newArray, newState, manifest, parentScope);
1052
1133
  return;
@@ -51,8 +51,11 @@ const copyString = (value, i, push) => {
51
51
  // Rewrite standalone references to loop-variable aliases inside an event-handler
52
52
  // expression into `$scope(this,'alias')` calls. Skips `@[...]` binding spans
53
53
  // (they keep their existing hydrate-time stringifying behavior), string literals,
54
- // and member accesses, so only identifiers that genuinely name a loop alias are
55
- // touched.
54
+ // member accesses, and object-literal keys (`{ alias: x }` keeps its key; the
55
+ // shorthand `{ alias }` expands to `{ alias: $scope(this,'alias') }`), so only
56
+ // identifiers that genuinely name a loop alias are touched. Bracket frames carry
57
+ // a pending-ternary count per nesting level, which is what tells an object key's
58
+ // `:` apart from a ternary's.
56
59
  export const rewriteHandlerAliases = (value, aliasSet) => {
57
60
  if (!aliasSet || aliasSet.size === 0 || typeof value !== 'string') return value;
58
61
 
@@ -62,6 +65,8 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
62
65
  };
63
66
  let i = 0;
64
67
  const n = value.length;
68
+ const frames = [{ bracket: '', ternaries: 0 }];
69
+ const frame = () => frames[frames.length - 1];
65
70
 
66
71
  while (i < n) {
67
72
  const ch = value[i];
@@ -92,6 +97,37 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
92
97
  continue;
93
98
  }
94
99
 
100
+ if (ch === '(' || ch === '[' || ch === '{') {
101
+ frames.push({ bracket: ch, ternaries: 0 });
102
+ push(ch);
103
+ i++;
104
+ continue;
105
+ }
106
+ if (ch === ')' || ch === ']' || ch === '}') {
107
+ if (frames.length > 1) frames.pop();
108
+ push(ch);
109
+ i++;
110
+ continue;
111
+ }
112
+ if (ch === '?') {
113
+ const next = value[i + 1];
114
+ if (next === '.' || next === '?') {
115
+ push(ch + next);
116
+ i += 2;
117
+ continue;
118
+ }
119
+ frame().ternaries++;
120
+ push(ch);
121
+ i++;
122
+ continue;
123
+ }
124
+ if (ch === ':') {
125
+ if (frame().ternaries > 0) frame().ternaries--;
126
+ push(ch);
127
+ i++;
128
+ continue;
129
+ }
130
+
95
131
  // Identifier — rewrite when it's a standalone alias reference.
96
132
  if (IDENT_START.test(ch)) {
97
133
  let j = i + 1;
@@ -99,7 +135,17 @@ export const rewriteHandlerAliases = (value, aliasSet) => {
99
135
  const ident = value.slice(i, j);
100
136
  const isMember = lastNonSpace(out) === '.';
101
137
  if (!isMember && aliasSet.has(ident)) {
102
- push(`$scope(this,'${ident}')`);
138
+ let k = j;
139
+ while (k < n && /\s/.test(value[k])) k++;
140
+ const next = value[k] || '';
141
+ const prev = lastNonSpace(out);
142
+ const inObject = frame().bracket === '{';
143
+ const isKey = inObject && next === ':' && frame().ternaries === 0;
144
+ const isShorthand =
145
+ inObject && (prev === '{' || prev === ',') && (next === ',' || next === '}');
146
+ if (isKey) push(ident);
147
+ else if (isShorthand) push(`${ident}: $scope(this,'${ident}')`);
148
+ else push(`$scope(this,'${ident}')`);
103
149
  } else {
104
150
  push(ident);
105
151
  }
package/runtime/parse.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  DOM_ELEMENT_PROPERTIES,
8
8
  DEHYDRATE_CLASS_OR_ATTR,
9
9
  THIS_PROP_REGEX,
10
+ STATE_THIS_PROP_REGEX,
10
11
  } from './constants.js';
11
12
  import { rewriteHandlerAliases } from './loop-scope.js';
12
13
 
@@ -57,6 +58,13 @@ const captureAttributeBindings = (element, aliasSet) => {
57
58
  if (v.includes('this.')) {
58
59
  const componentId = findComponentIdForElement(element);
59
60
  if (componentId) {
61
+ // `$.this.X` (component-state write through the root) must be
62
+ // consumed as one reference BEFORE the bare `this.X` pass — that
63
+ // pass alone would leave the `$.` prefix behind and produce
64
+ // `$.$['id'].X`. Runtime-fetched components arrive with this form
65
+ // already rewritten by component.js; compiled pages inline the
66
+ // authored form, so parse meets it raw.
67
+ v = v.replace(STATE_THIS_PROP_REGEX, (_, prop) => `$['${componentId}'].${prop}`);
60
68
  v = v.replace(THIS_PROP_REGEX, (match, prop) =>
61
69
  DOM_ELEMENT_PROPERTIES.has(prop) ? match : `$['${componentId}'].${prop}`,
62
70
  );
@@ -374,172 +374,73 @@ export const restoreMarkersFromManifest = (
374
374
  }
375
375
  }
376
376
 
377
- // Handle conditional and iteration children - process them at THIS level before recursing
378
- // These are comment nodes in the DOM, not elements, so we need to find them here
377
+ // Restore iterations/conditionals at THIS level by their childNodes index.
378
+ // The manifest key encodes the start comment's pre-stamp position
379
+ // (`conditional_13` = element.childNodes[13]); processing in ascending
380
+ // order keeps every index valid, because each restoration returns its
381
+ // region to the exact pre-stamp node count before the next index is
382
+ // consulted. Locating by index — not by expression text — is what
383
+ // disambiguates sibling directives that share the same expression.
379
384
  if (tree.children) {
380
- for (const key in tree.children) {
385
+ const directives = Object.keys(tree.children)
386
+ .filter((key) => {
387
+ const type = tree.children[key].type;
388
+ return type === "iteration" || type === "conditional";
389
+ })
390
+ .map((key) => ({ key, index: Number(key.match(/_(\d+)$/)?.[1]) }))
391
+ .filter(({ index }) => Number.isInteger(index))
392
+ .sort((a, b) => a.index - b.index);
393
+
394
+ for (const { key, index } of directives) {
381
395
  const childTree = tree.children[key];
396
+ const restoration = childTree.compiled?.restoration;
382
397
 
383
- // Handle iteration restoration
384
- if (childTree.type === "iteration" && childTree.compiled?.restoration) {
385
- const restoration = childTree.compiled.restoration;
386
-
387
- // Find iteration comment by matching the expression
388
- let startComment = null;
389
- let endComment = null;
390
- let depth = 0;
391
-
392
- const walker = document.createTreeWalker(
393
- element,
394
- NodeFilter.SHOW_COMMENT,
395
- );
396
- while (walker.nextNode()) {
397
- const comment = walker.currentNode;
398
- const trimmed = comment.textContent.trim();
399
-
400
- // Skip already processed iterations
401
- if (comment._vibeProcessed) continue;
402
-
403
- // Match by expression: "each items as item, i"
404
- const expectedComment = `each ${restoration.expression}`;
405
- if (trimmed === expectedComment && !startComment) {
406
- startComment = comment;
407
- depth = 1;
408
- } else if (startComment) {
409
- if (trimmed.startsWith("each ")) {
410
- depth++;
411
- } else if (trimmed === "/each") {
412
- depth--;
413
- if (depth === 0) {
414
- endComment = comment;
415
- break;
416
- }
417
- }
418
- }
419
- }
420
-
421
- // Always delete the iteration node from tree - runtime will re-create it
422
- // Do this even if comments weren't found (they might have been removed by parent restoration)
423
- delete tree.children[key];
424
-
425
- if (startComment && endComment) {
426
- // Mark as processed
427
- startComment._vibeProcessed = true;
428
- endComment._vibeProcessed = true;
429
-
430
- const parent = startComment.parentNode;
431
- const insertionPoint = endComment.nextSibling;
398
+ // Always delete the directive node from the tree — the runtime
399
+ // re-creates it when it parses the restored DOM.
400
+ delete tree.children[key];
432
401
 
433
- // Remove all pre-rendered content between comments
434
- let current = startComment.nextSibling;
435
- while (current && current !== endComment) {
436
- const next = current.nextSibling;
437
- if (current.nodeType !== Node.COMMENT_NODE) {
438
- current.remove();
439
- }
440
- current = next;
441
- }
442
-
443
- // Insert the template (single item)
444
- const tempContainer = document.createElement("div");
445
- tempContainer.innerHTML = restoration.template;
446
-
447
- const fragment = document.createDocumentFragment();
448
- while (tempContainer.firstChild) {
449
- fragment.appendChild(tempContainer.firstChild);
450
- }
451
- parent.insertBefore(fragment, endComment);
452
- }
402
+ if (!restoration?.template) continue;
453
403
 
404
+ const startComment = element.childNodes[index];
405
+ if (!startComment || startComment.nodeType !== Node.COMMENT_NODE) {
454
406
  continue;
455
407
  }
456
408
 
457
- if (
458
- childTree.type === "conditional" &&
459
- childTree.compiled?.restoration
460
- ) {
461
- const restoration = childTree.compiled.restoration;
462
-
463
- // Find conditional comment markers in the current element
464
- // But SKIP conditionals that are inside iteration blocks
465
- const walker = document.createTreeWalker(
466
- element,
467
- NodeFilter.SHOW_COMMENT,
468
- );
469
- let startComment = null;
470
- let endComment = null;
471
- let insideIteration = false;
472
- let conditionalDepth = 0;
473
-
474
- while (walker.nextNode()) {
475
- const comment = walker.currentNode;
476
- const text = comment.textContent.trim();
477
-
478
- // Track if we're inside an iteration block
479
- if (text.startsWith("each ")) {
480
- insideIteration = true;
481
- continue;
482
- } else if (text === "/each") {
483
- insideIteration = false;
484
- continue;
485
- }
486
-
487
- // Skip conditionals inside iterations - runtime will handle them
488
- if (insideIteration) continue;
489
-
490
- // Skip already processed conditionals
491
- if (comment._vibeProcessed) continue;
492
-
493
- if (text.startsWith("if")) {
494
- if (!startComment) {
495
- const expression = childTree.meta?.expression;
496
- const expectedText = expression ? "if " + expression : null;
497
- if (expectedText && text === expectedText) {
498
- startComment = comment;
499
- conditionalDepth = 1;
500
- }
501
- } else {
502
- // Track nested conditionals
503
- conditionalDepth++;
504
- }
505
- } else if (text === "/if" && startComment) {
506
- conditionalDepth--;
507
- if (conditionalDepth === 0) {
508
- endComment = comment;
509
- break;
510
- }
409
+ // Find the matching end marker at this sibling level, depth-counted
410
+ // so nested same-kind directives inside the region don't end it early.
411
+ const isIteration = childTree.type === "iteration";
412
+ const openPrefix = isIteration ? "each " : "if ";
413
+ const closeMarker = isIteration ? "/each" : "/if";
414
+ let depth = 0;
415
+ let endComment = null;
416
+ for (let cur = startComment.nextSibling; cur; cur = cur.nextSibling) {
417
+ if (cur.nodeType !== Node.COMMENT_NODE) continue;
418
+ const text = cur.textContent.trim();
419
+ if (text.startsWith(openPrefix)) {
420
+ depth++;
421
+ } else if (text === closeMarker) {
422
+ if (depth === 0) {
423
+ endComment = cur;
424
+ break;
511
425
  }
426
+ depth--;
512
427
  }
513
-
514
- // Always delete the conditional node from tree - runtime will re-create it
515
- // Do this even if comments weren't found (they might have been removed by parent restoration)
516
- delete tree.children[key];
517
-
518
- if (startComment && endComment) {
519
- // Mark as processed
520
- startComment._vibeProcessed = true;
521
- endComment._vibeProcessed = true;
522
-
523
- // Remove ALL pre-rendered content between start and end comments
524
- // This includes the <!-- else --> marker from compiled HTML
525
- let current = startComment.nextSibling;
526
- while (current && current !== endComment) {
527
- const next = current.nextSibling;
528
- current.remove(); // Remove ALL nodes, including comment nodes
529
- current = next;
530
- }
531
-
532
- // Insert the template content BEFORE endComment
533
- const tempContainer = document.createElement("div");
534
- tempContainer.innerHTML = restoration.template;
535
-
536
- const fragment = document.createDocumentFragment();
537
- while (tempContainer.firstChild) {
538
- fragment.appendChild(tempContainer.firstChild);
539
- }
540
- endComment.parentNode.insertBefore(fragment, endComment);
541
- }
542
428
  }
429
+ if (!endComment) continue;
430
+
431
+ // Drop the stamped content (including the <!-- else --> marker and
432
+ // any stamped-row comments) and re-insert the pre-stamp template —
433
+ // node-for-node identical to what the manifest was built against.
434
+ let current = startComment.nextSibling;
435
+ while (current && current !== endComment) {
436
+ const next = current.nextSibling;
437
+ current.remove();
438
+ current = next;
439
+ }
440
+
441
+ const tpl = document.createElement("template");
442
+ tpl.innerHTML = restoration.template;
443
+ endComment.parentNode.insertBefore(tpl.content, endComment);
543
444
  }
544
445
  }
545
446
 
package/runtime/utils.js CHANGED
@@ -46,13 +46,23 @@ export const setRootState = (proxy) => { rootProxy = proxy; };
46
46
  // delegate to the live root (their target is `$`), so they need no wrapper.
47
47
  const dollarCache = new WeakMap();
48
48
  const RESERVED_PROBE = 'unsafe'; // reserved method present on root-backed states, absent on plain snapshots
49
+ // The fallback serves ONLY the root's non-enumerable helper methods. A state
50
+ // key that is merely missing from this snapshot must read as undefined — the
51
+ // old/new snapshot diff depends on it. Letting it leak through to the live
52
+ // root would make both sides of the diff read the same current value (e.g. a
53
+ // component state bucket registered mid-cycle), silently defeating change
54
+ // detection.
55
+ const rootHelper = (k) => {
56
+ const desc = Object.getOwnPropertyDescriptor(rootProxy, k);
57
+ return desc && !desc.enumerable;
58
+ };
49
59
  const dollarFor = (state) => {
50
60
  if (!rootProxy || state === rootProxy || RESERVED_PROBE in state) return state;
51
61
  let wrapped = dollarCache.get(state);
52
62
  if (!wrapped) {
53
63
  wrapped = new Proxy(state, {
54
- get: (t, k) => (k in t ? t[k] : rootProxy[k]),
55
- has: (t, k) => k in t || k in rootProxy,
64
+ get: (t, k) => (k in t ? t[k] : rootHelper(k) ? rootProxy[k] : undefined),
65
+ has: (t, k) => k in t || rootHelper(k),
56
66
  });
57
67
  dollarCache.set(state, wrapped);
58
68
  }