@ape-egg/vibe 1.7.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,621 @@
1
+ import { ITERATION_START_REGEX, CONDITIONAL_START_REGEX } from './constants.js';
2
+
3
+ // Reconcile a live, Vibe-managed subtree against new source HTML, applying
4
+ // the minimal mutation needed to align them. Vibe-owned regions (iterations,
5
+ // conditionals, components) are treated as opaque — their internals are
6
+ // state-driven and owned by iterate.js / conditionals.js / component.js.
7
+ //
8
+ // Walking strategy: tag-aligned, two-pointer. For each pair we either
9
+ // - match (recurse into element children, or update text/attrs in place),
10
+ // - skip (Vibe-owned region; jump both pointers past it),
11
+ // - insert (source has a node live doesn't), or
12
+ // - remove (live has a node source doesn't).
13
+ // DOM identity survives wherever a match is found.
14
+
15
+ const COMMENT = 8;
16
+ const ELEMENT = 1;
17
+ const TEXT = 3;
18
+
19
+ const isIterationStart = (n) =>
20
+ n.nodeType === COMMENT && ITERATION_START_REGEX.test(n.textContent.trim());
21
+ const isConditionalStart = (n) =>
22
+ n.nodeType === COMMENT && CONDITIONAL_START_REGEX.test(n.textContent.trim());
23
+ const isRegionEnd = (n) => {
24
+ if (n.nodeType !== COMMENT) return false;
25
+ const t = n.textContent.trim();
26
+ return t === '/each' || t === '/if';
27
+ };
28
+ const isComponentWrapper = (n) =>
29
+ n.nodeType === ELEMENT &&
30
+ (n.tagName === 'COMPONENT' || (n.tagName === 'DIV' && n.classList?.contains('component')));
31
+
32
+ // <slot> element pairs are opaque: the slot's children are owned by the
33
+ // CALLER of the component, not the template being reconciled. So if source
34
+ // is a component template (empty <slot></slot>) and live is the processed
35
+ // wrapper (populated <slot>), recursing in would destroy the slot content.
36
+ // Slot content is updated by the page-update path's component-pair handler,
37
+ // which recurses INTO the slot (bypassing the slot element itself).
38
+ const isSlotElement = (n) =>
39
+ n.nodeType === ELEMENT &&
40
+ (n.tagName === 'SLOT' || (n.tagName === 'DIV' && n.classList?.contains('slot')));
41
+
42
+ // Two nodes "look matchable" — same nodeType, same tag for elements. Used
43
+ // by the realignment lookahead below.
44
+ const nodesMatch = (a, b) => {
45
+ if (!a || !b) return false;
46
+ if (a.nodeType !== b.nodeType) return false;
47
+ if (a.nodeType === ELEMENT) return a.tagName === b.tagName;
48
+ return true;
49
+ };
50
+
51
+ // At a mismatch, scan ahead within a small window on both sides to find a
52
+ // near-match. Returns { dl, ds } where dl live nodes need to be removed and
53
+ // ds source nodes need to be inserted before the realignment. Avoids the
54
+ // cascading wholesale-replace failure mode where a single extra node on one
55
+ // side destroys everything that follows.
56
+ const REALIGN_WINDOW = 4;
57
+ const findRealignment = (liveNodes, li, sourceNodes, si) => {
58
+ for (let total = 1; total <= 2 * REALIGN_WINDOW; total++) {
59
+ for (let dl = 0; dl <= Math.min(total, REALIGN_WINDOW); dl++) {
60
+ const ds = total - dl;
61
+ if (ds < 0 || ds > REALIGN_WINDOW) continue;
62
+ if (dl === 0 && ds === 0) continue;
63
+ if (nodesMatch(liveNodes[li + dl], sourceNodes[si + ds])) {
64
+ return { dl, ds };
65
+ }
66
+ }
67
+ }
68
+ return null;
69
+ };
70
+
71
+ // Trim leading/trailing whitespace-only text nodes. Mirrors vibe's
72
+ // component.js, which calls .trim() on slot content before inlining — so
73
+ // the live <slot> has no edge whitespace, but DOMParser preserves it on
74
+ // the source side. Used when recursing into component slots.
75
+ const trimWsEdges = (nodes) => {
76
+ let start = 0;
77
+ let end = nodes.length;
78
+ while (start < end && nodes[start].nodeType === TEXT && !nodes[start].textContent.trim()) start++;
79
+ while (end > start && nodes[end - 1].nodeType === TEXT && !nodes[end - 1].textContent.trim()) end--;
80
+ return nodes.slice(start, end);
81
+ };
82
+
83
+ // Find the <slot> in `el` that holds the source's slot content. Vibe wraps
84
+ // slot content in a <slot> element after inlining; with nested components
85
+ // (e.g. Layout uses Authorization as a wrapping component, and Layout's own
86
+ // <slot> ends up inside Authorization's slot), there can be many <slot>s in
87
+ // the wrapper. The right one is the slot whose first element child has the
88
+ // same tag as the source's first element child (that's the user's content).
89
+ const firstElementChildOf = (parent) => {
90
+ for (const n of parent.children || []) return n;
91
+ return null;
92
+ };
93
+ const firstElementOfNodes = (nodes) => {
94
+ for (const n of nodes) if (n.nodeType === ELEMENT) return n;
95
+ return null;
96
+ };
97
+ const findOwnSlot = (el, srcChildren) => {
98
+ const srcFirst = firstElementOfNodes(srcChildren);
99
+ if (!srcFirst) return null;
100
+ for (const slot of el.querySelectorAll('slot')) {
101
+ const liveFirst = firstElementChildOf(slot);
102
+ if (liveFirst?.tagName === srcFirst.tagName) return slot;
103
+ }
104
+ return null;
105
+ };
106
+
107
+ const hasBinding = (s) => /@\[.+?\]/.test(s);
108
+
109
+ // Per-text-node cache of the last source text we saw (containing @[...]).
110
+ // Used to surgically update STATIC portions of bound text — e.g. editing
111
+ // "Level @[lvl]" to "Level: @[lvl]" — by extracting the binding's resolved
112
+ // value from live using the OLD statics and re-applying with NEW statics.
113
+ //
114
+ // Caveat: the very first reconcile populates the cache without applying
115
+ // anything, so a freshly-loaded page needs ONE wasted save to seed the
116
+ // cache. Subsequent edits propagate.
117
+ const boundSourceCache = new WeakMap();
118
+
119
+ const updateBoundText = (live, newSrc, parent, log) => {
120
+ const oldSrc = boundSourceCache.get(live);
121
+ boundSourceCache.set(live, newSrc);
122
+ if (oldSrc === undefined || oldSrc === newSrc) return;
123
+
124
+ // Bindings must match in count + order to safely propagate static changes.
125
+ // (If the user added or reordered bindings, we can't infer where values go.)
126
+ const oldBindings = oldSrc.match(/@\[[^\]]+\]/g) || [];
127
+ const newBindings = newSrc.match(/@\[[^\]]+\]/g) || [];
128
+ if (oldBindings.length !== newBindings.length) return;
129
+ for (let i = 0; i < oldBindings.length; i++) {
130
+ if (oldBindings[i] !== newBindings[i]) return;
131
+ }
132
+
133
+ // Split each source by binding markers → static segments around each binding.
134
+ const oldStatic = oldSrc.split(/@\[[^\]]+\]/);
135
+ const newStatic = newSrc.split(/@\[[^\]]+\]/);
136
+
137
+ // Extract resolved values from live by stripping the old statics.
138
+ const liveText = live.textContent;
139
+ const values = [];
140
+ let pos = 0;
141
+ for (let i = 0; i < oldStatic.length - 1; i++) {
142
+ const before = oldStatic[i];
143
+ const after = oldStatic[i + 1];
144
+ if (liveText.slice(pos, pos + before.length) !== before) return;
145
+ pos += before.length;
146
+ let endPos;
147
+ if (i === oldStatic.length - 2) {
148
+ endPos = liveText.length - after.length;
149
+ if (endPos < pos || liveText.slice(endPos) !== after) return;
150
+ } else {
151
+ endPos = liveText.indexOf(after, pos);
152
+ if (endPos < 0) return;
153
+ }
154
+ values.push(liveText.slice(pos, endPos));
155
+ pos = endPos;
156
+ }
157
+
158
+ let result = newStatic[0];
159
+ for (let i = 0; i < values.length; i++) {
160
+ result += values[i] + newStatic[i + 1];
161
+ }
162
+ if (result === liveText) return;
163
+
164
+ live.textContent = result;
165
+ log.text++;
166
+ log.changes.push(`bound text in ${describe(parent)}: ${JSON.stringify(result.slice(0, 60))}`);
167
+ };
168
+
169
+ // Find the matching closing comment (`<!-- /each -->` or `<!-- /if -->`) for
170
+ // a region starting at startIdx. Tracks depth so nested regions are handled.
171
+ // `endLimit` bounds the search (defaults to nodes.length).
172
+ const findRegionEnd = (nodes, startIdx, endLimit) => {
173
+ const limit = endLimit ?? nodes.length;
174
+ let depth = 1;
175
+ for (let i = startIdx + 1; i < limit; i++) {
176
+ const n = nodes[i];
177
+ if (isIterationStart(n) || isConditionalStart(n)) depth++;
178
+ else if (isRegionEnd(n)) {
179
+ depth--;
180
+ if (depth === 0) return i;
181
+ }
182
+ }
183
+ return limit - 1;
184
+ };
185
+
186
+ // Find the depth-0 `<!-- else -->` marker between an `<!-- if -->` and its
187
+ // `<!-- /if -->`. Returns -1 if there's no else branch.
188
+ const findElseMarker = (nodes, startIdx, endIdx) => {
189
+ let depth = 0;
190
+ for (let i = startIdx; i < endIdx; i++) {
191
+ const n = nodes[i];
192
+ if (n.nodeType !== COMMENT) continue;
193
+ const t = n.textContent.trim();
194
+ if (t.startsWith('if ') || t.startsWith('each ')) depth++;
195
+ else if (t === '/if' || t === '/each') depth--;
196
+ else if (depth === 0 && t === 'else') return i;
197
+ }
198
+ return -1;
199
+ };
200
+
201
+ // Framework-managed markers that reconcile must not strip on source absence.
202
+ // `vibe-fouc` can appear as either an attribute or a class token (per
203
+ // vibe.css and runtime/cleanup.js) — both forms are protected below.
204
+ const PRESERVED_ATTRS = new Set(['vibe-fouc']);
205
+ const PRESERVED_CLASSES = new Set(['vibe-fouc']);
206
+ const isPreservedAttr = (name) => name.startsWith('data-vibe-') || PRESERVED_ATTRS.has(name);
207
+
208
+ // Merge a source `class` value with any preserved tokens already on live, so
209
+ // framework-managed tokens (e.g. .vibe-fouc) survive reconciliation.
210
+ const mergeClass = (srcValue, live) => {
211
+ const tokens = new Set(srcValue.split(/\s+/).filter(Boolean));
212
+ for (const t of live.classList) {
213
+ if (PRESERVED_CLASSES.has(t)) tokens.add(t);
214
+ }
215
+ return [...tokens].join(' ');
216
+ };
217
+
218
+ // A description string for an element, for log readability.
219
+ const describe = (el) => {
220
+ if (!el || el.nodeType !== ELEMENT) return String(el?.nodeName || el);
221
+ const id = el.id ? '#' + el.id : '';
222
+ const cls = el.classList?.length ? '.' + [...el.classList].join('.') : '';
223
+ return el.tagName.toLowerCase() + id + cls;
224
+ };
225
+
226
+ const reconcileAttributes = (live, src, log) => {
227
+ // Detect vibe name-bindings: source attribute NAMES containing @[...]
228
+ // (e.g. <page @[pageName]>). Vibe resolves these to dynamic attribute
229
+ // names at runtime, so the live element has whatever name `@[pageName]`
230
+ // evaluated to (e.g. `brawlers`). We can't add the literal `@[pagename]`
231
+ // attribute, and we can't reliably tell which live attribute maps to the
232
+ // binding — so when any name binding is present, skip the strip phase
233
+ // entirely. Vibe owns this element's attribute set.
234
+ let hasNameBinding = false;
235
+ for (const attr of src.attributes) {
236
+ if (attr.name.includes('@[')) { hasNameBinding = true; continue; }
237
+ // Source-bound attribute? hydrate owns its value — don't overwrite.
238
+ if (hasBinding(attr.value)) continue;
239
+ const next = attr.name === 'class' ? mergeClass(attr.value, live) : attr.value;
240
+ if (live.getAttribute(attr.name) !== next) {
241
+ live.setAttribute(attr.name, next);
242
+ log.attr++;
243
+ log.changes.push(`attr ${describe(live)} [${attr.name}=${JSON.stringify(next)}]`);
244
+ }
245
+ }
246
+ if (hasNameBinding) return;
247
+ for (const attr of [...live.attributes]) {
248
+ if (src.hasAttribute(attr.name)) continue;
249
+ if (isPreservedAttr(attr.name)) continue;
250
+ // data-X → X mirror: if source declares `data-foo`, don't strip live's
251
+ // `foo`. App code commonly mirrors data-* attrs into their canonical
252
+ // form (e.g. <img data-src="..."> with a runtime sync that sets `src`).
253
+ // Stripping `src` here would briefly blank the image until the next sync.
254
+ if (src.hasAttribute('data-' + attr.name)) continue;
255
+ // Source omitted `class` entirely — keep only preserved tokens (or drop).
256
+ if (attr.name === 'class') {
257
+ const kept = [...live.classList].filter((t) => PRESERVED_CLASSES.has(t));
258
+ if (kept.length) live.setAttribute('class', kept.join(' '));
259
+ else live.removeAttribute('class');
260
+ log.attr++;
261
+ log.changes.push(`attr ${describe(live)} [class trimmed]`);
262
+ continue;
263
+ }
264
+ live.removeAttribute(attr.name);
265
+ log.attr++;
266
+ log.changes.push(`attr ${describe(live)} [-${attr.name}]`);
267
+ }
268
+ };
269
+
270
+ // Reconcile a slice of liveParent's children (liveNodes[liStart..liEnd))
271
+ // against srcNodes[siStart..sEnd). Mutations target liveParent. liveNodes
272
+ // is a stable snapshot of liveParent.childNodes captured before mutation;
273
+ // indices into it remain valid even after live nodes get removed/replaced.
274
+ //
275
+ // `insideIteration` propagates through recursion. When true, component
276
+ // wrappers are left untouched: iterate.js owns re-rendering via state
277
+ // changes, and pre-resolves `@[item.x]` props to literals before processing
278
+ // — so the live/source prop shapes intentionally differ. Trying to re-mount
279
+ // would insert a fresh <component src> outside iteration scope, losing
280
+ // `item`/`index` and rendering `undefined`.
281
+ const reconcileRange = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log, insideIteration = false) => {
282
+ let li = liStart;
283
+ let si = siStart;
284
+
285
+ while (li < liEnd || si < sEnd) {
286
+ const live = li < liEnd ? liveNodes[li] : null;
287
+ const src = si < sEnd ? srcNodes[si] : null;
288
+
289
+ if (!live) {
290
+ liveParent.appendChild(srcNodes[si].cloneNode(true));
291
+ log.insert++;
292
+ log.changes.push(`insert ${describe(srcNodes[si])} into ${describe(liveParent)}`);
293
+ si++;
294
+ continue;
295
+ }
296
+ if (!src) {
297
+ log.remove++;
298
+ log.changes.push(`remove ${describe(live)} from ${describe(liveParent)}`);
299
+ live.remove();
300
+ li++;
301
+ continue;
302
+ }
303
+
304
+ // <script> elements are side-effecting (execute on insertion) and vibe's
305
+ // component pipeline strips them after running. So source typically still
306
+ // has them while live doesn't. Skip orphan scripts on either side rather
307
+ // than insert/remove — preserves correctness without re-executing or
308
+ // accumulating dead scripts on each reconcile.
309
+ const srcIsScript = src?.nodeType === ELEMENT && src.tagName === 'SCRIPT';
310
+ const liveIsScript = live?.nodeType === ELEMENT && live.tagName === 'SCRIPT';
311
+ if (srcIsScript && !liveIsScript) { si++; continue; }
312
+ if (liveIsScript && !srcIsScript) { li++; continue; }
313
+
314
+ const liveIsRegion = isIterationStart(live) || isConditionalStart(live);
315
+ const srcIsRegion = isIterationStart(src) || isConditionalStart(src);
316
+
317
+ // Both pointers at a Vibe-owned region — recurse based on kind.
318
+ if (liveIsRegion && srcIsRegion) {
319
+ const liveEnd = findRegionEnd(liveNodes, li, liEnd);
320
+ const srcEnd = findRegionEnd(srcNodes, si, sEnd);
321
+ if (isConditionalStart(live) && isConditionalStart(src)) {
322
+ reconcileConditionalRegion(liveParent, liveNodes, li, liveEnd, srcNodes, si, srcEnd, log, insideIteration);
323
+ } else if (isIterationStart(live) && isIterationStart(src)) {
324
+ reconcileIterationRegion(liveParent, liveNodes, li, liveEnd, srcNodes, si, srcEnd, log);
325
+ }
326
+ // Mismatched region kinds (if vs each) → opaque, skip both.
327
+ li = liveEnd + 1;
328
+ si = srcEnd + 1;
329
+ continue;
330
+ }
331
+ // Source removed a region that's still live — drop the live region.
332
+ if (liveIsRegion) {
333
+ const end = findRegionEnd(liveNodes, li, liEnd);
334
+ for (let k = li; k <= end; k++) {
335
+ log.remove++;
336
+ liveNodes[k].remove();
337
+ }
338
+ log.changes.push(`remove vibe region from ${describe(liveParent)}`);
339
+ li = end + 1;
340
+ continue;
341
+ }
342
+ // Source added a new region — insert it wholesale; MutationObserver
343
+ // will pick up the new comments + children and process them.
344
+ if (srcIsRegion) {
345
+ const end = findRegionEnd(srcNodes, si, sEnd);
346
+ for (let k = si; k <= end; k++) {
347
+ liveParent.insertBefore(srcNodes[k].cloneNode(true), live);
348
+ log.insert++;
349
+ }
350
+ log.changes.push(`insert vibe region into ${describe(liveParent)}`);
351
+ si = end + 1;
352
+ continue;
353
+ }
354
+
355
+ // Component wrappers: the wrapper itself + its inlined template are
356
+ // opaque (state-driven, owned by component.js + the surrounding hydrate).
357
+ // But the SLOT content is user-authored static HTML — recurse into it so
358
+ // edits to a page's slot content (e.g. inside Layout's <slot>) reconcile
359
+ // surgically just like top-level static content.
360
+ //
361
+ // First, check for PROP changes. If the HMR spy in vite-plugin-vibe has
362
+ // stashed the original authored attrs on live._vibeProps, compare them to
363
+ // the source's attrs. Any diff means the callsite re-authored a prop —
364
+ // re-mount by replacing the wrapper with a fresh <component src> so
365
+ // component.js re-fetches and re-inlines with the new values.
366
+ if (isComponentWrapper(live) && isComponentWrapper(src)) {
367
+ // Inside an iteration instance, iterate.js owns re-mounting: props are
368
+ // pre-resolved against iteration scope before processComponent runs, so
369
+ // source `hp="@[item.x]"` intentionally doesn't match live `hp="50"`.
370
+ // Skip the prop-diff + slot recursion entirely; iteration re-renders
371
+ // the instance when its state changes.
372
+ if (insideIteration) {
373
+ li++;
374
+ si++;
375
+ continue;
376
+ }
377
+ if (live._vibeProps && src.hasAttribute('src')) {
378
+ // vibe-fouc is transient and may appear as either an attribute or a
379
+ // class token (see runtime/cleanup.js#FOUC_CLASS_OR_ATTR) — normalize
380
+ // both out so a FOUC flip never reads as a prop change.
381
+ const srcProps = {};
382
+ for (const a of src.attributes) {
383
+ if (a.name === 'vibe-fouc') continue;
384
+ if (a.name === 'class') {
385
+ const kept = a.value.split(/\s+/).filter((t) => t && t !== 'vibe-fouc');
386
+ if (kept.length) srcProps.class = kept.join(' ');
387
+ continue;
388
+ }
389
+ srcProps[a.name] = a.value;
390
+ }
391
+ const liveProps = live._vibeProps;
392
+ const liveKeys = Object.keys(liveProps);
393
+ const srcKeys = Object.keys(srcProps);
394
+ const diff =
395
+ liveKeys.length !== srcKeys.length ||
396
+ liveKeys.some((k) => liveProps[k] !== srcProps[k]) ||
397
+ srcKeys.some((k) => !(k in liveProps));
398
+
399
+ if (diff) {
400
+ const fresh = document.createElement(src.tagName);
401
+ if (src.tagName === 'DIV') fresh.className = 'component';
402
+ for (const [name, value] of Object.entries(srcProps)) {
403
+ fresh.setAttribute(name, value);
404
+ }
405
+ fresh.setAttribute('vibe-fouc', '');
406
+ const slotHtml = src.innerHTML.trim();
407
+ fresh._vibeSlotContent = slotHtml;
408
+ // Plugin mirror: _vibeSlotContent is deleted synchronously by
409
+ // vibe's processSingle, so the spy falls back to innerHTML (empty)
410
+ // on the next mount cycle. Without this, the next re-mount would
411
+ // read an empty slot. See vite-plugin-vibe for the symmetric read.
412
+ fresh._vibePluginSlot = slotHtml;
413
+ live.replaceWith(fresh);
414
+ log.replace++;
415
+ log.changes.push(
416
+ `re-mount ${describe(live)} (prop change) live=${JSON.stringify(liveProps)} src=${JSON.stringify(srcProps)}`,
417
+ );
418
+ li++;
419
+ si++;
420
+ continue;
421
+ }
422
+ }
423
+
424
+ const srcChildren = trimWsEdges([...src.childNodes]);
425
+ const liveSlot = findOwnSlot(live, srcChildren);
426
+ if (liveSlot) reconcileChildren(liveSlot, srcChildren, log, insideIteration);
427
+ li++;
428
+ si++;
429
+ continue;
430
+ }
431
+
432
+ // <slot> elements are opaque (slot content is caller-owned).
433
+ if (isSlotElement(live) && isSlotElement(src)) {
434
+ li++;
435
+ si++;
436
+ continue;
437
+ }
438
+
439
+ // Text nodes — update only if source is static. Bound text uses the
440
+ // per-node source cache to surgically re-apply changed STATICS while
441
+ // preserving the binding's resolved value (see updateBoundText above).
442
+ if (live.nodeType === TEXT && src.nodeType === TEXT) {
443
+ if (hasBinding(src.textContent)) {
444
+ updateBoundText(live, src.textContent, liveParent, log);
445
+ } else if (live.textContent !== src.textContent) {
446
+ live.textContent = src.textContent;
447
+ log.text++;
448
+ const preview = src.textContent.trim().slice(0, 60);
449
+ log.changes.push(`text in ${describe(liveParent)}: ${JSON.stringify(preview)}`);
450
+ }
451
+ li++;
452
+ si++;
453
+ continue;
454
+ }
455
+
456
+ // Plain comment nodes (non-region) — update text if changed.
457
+ if (live.nodeType === COMMENT && src.nodeType === COMMENT) {
458
+ if (live.textContent !== src.textContent) {
459
+ live.textContent = src.textContent;
460
+ log.text++;
461
+ }
462
+ li++;
463
+ si++;
464
+ continue;
465
+ }
466
+
467
+ // Element pair, same tag → reconcile in place. Identity preserved.
468
+ if (live.nodeType === ELEMENT && src.nodeType === ELEMENT &&
469
+ live.tagName === src.tagName) {
470
+ reconcileAttributes(live, src, log);
471
+ reconcileChildren(live, [...src.childNodes], log, insideIteration);
472
+ li++;
473
+ si++;
474
+ continue;
475
+ }
476
+
477
+ // Before falling through to wholesale replace, try a small lookahead
478
+ // realignment — handles cases where one side has an inserted/removed
479
+ // node that would otherwise cascade into a chain of bad replaces.
480
+ const align = findRealignment(liveNodes, li, srcNodes, si);
481
+ if (align && li + align.dl < liEnd && si + align.ds < sEnd) {
482
+ for (let i = 0; i < align.dl; i++) {
483
+ const n = liveNodes[li + i];
484
+ if (n.nodeType === ELEMENT && n.tagName === 'SCRIPT') continue; // see orphan-script note above
485
+ log.remove++;
486
+ log.changes.push(`remove (extra) ${describe(n)} from ${describe(liveParent)}`);
487
+ n.remove();
488
+ }
489
+ const anchor = liveNodes[li + align.dl];
490
+ for (let i = 0; i < align.ds; i++) {
491
+ const n = srcNodes[si + i];
492
+ if (n.nodeType === ELEMENT && n.tagName === 'SCRIPT') continue;
493
+ log.insert++;
494
+ log.changes.push(`insert (new) ${describe(n)} into ${describe(liveParent)}`);
495
+ liveParent.insertBefore(n.cloneNode(true), anchor);
496
+ }
497
+ li += align.dl;
498
+ si += align.ds;
499
+ continue;
500
+ }
501
+
502
+ // Truly mismatched (no realignment possible) — replace.
503
+ log.replace++;
504
+ log.changes.push(`replace ${describe(live)} → ${describe(src)} in ${describe(liveParent)}`);
505
+ live.replaceWith(src.cloneNode(true));
506
+ li++;
507
+ si++;
508
+ }
509
+ };
510
+
511
+ const reconcileChildren = (liveParent, sourceNodes, log, insideIteration = false) => {
512
+ const liveNodes = [...liveParent.childNodes];
513
+ reconcileRange(liveParent, liveNodes, 0, liveNodes.length, sourceNodes, 0, sourceNodes.length, log, insideIteration);
514
+ };
515
+
516
+ // Walk a conditional region: live has one active branch; source has the
517
+ // if-branch and (optionally) the else-branch separated by `<!-- else -->`.
518
+ // Choose the source branch whose first element tag matches live's first
519
+ // element tag, then recurse range-vs-range. If neither matches, leave the
520
+ // region alone (opaque) — vibe will refresh it on next state change.
521
+ const reconcileConditionalRegion = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log, insideIteration = false) => {
522
+ // Source branches.
523
+ const elseIdx = findElseMarker(srcNodes, siStart + 1, sEnd);
524
+ const ifStart = siStart + 1;
525
+ const ifEnd = elseIdx === -1 ? sEnd : elseIdx;
526
+ const elseStart = elseIdx === -1 ? sEnd : elseIdx + 1;
527
+ const elseEnd = sEnd;
528
+
529
+ // First element on each side decides which branch is live.
530
+ let liveFirstEl = null;
531
+ for (let i = liStart + 1; i < liEnd; i++) {
532
+ if (liveNodes[i].nodeType === ELEMENT) { liveFirstEl = liveNodes[i]; break; }
533
+ }
534
+ if (!liveFirstEl) return; // empty branch; nothing to walk
535
+
536
+ let chosenStart = -1;
537
+ let chosenEnd = -1;
538
+ for (let i = ifStart; i < ifEnd; i++) {
539
+ if (srcNodes[i].nodeType === ELEMENT) {
540
+ if (srcNodes[i].tagName === liveFirstEl.tagName) {
541
+ chosenStart = ifStart; chosenEnd = ifEnd;
542
+ }
543
+ break;
544
+ }
545
+ }
546
+ if (chosenStart === -1) {
547
+ for (let i = elseStart; i < elseEnd; i++) {
548
+ if (srcNodes[i].nodeType === ELEMENT) {
549
+ if (srcNodes[i].tagName === liveFirstEl.tagName) {
550
+ chosenStart = elseStart; chosenEnd = elseEnd;
551
+ }
552
+ break;
553
+ }
554
+ }
555
+ }
556
+ if (chosenStart === -1) return; // no matching branch; leave opaque
557
+
558
+ reconcileRange(
559
+ liveParent, liveNodes,
560
+ liStart + 1, liEnd,
561
+ srcNodes, chosenStart, chosenEnd,
562
+ log,
563
+ insideIteration,
564
+ );
565
+ };
566
+
567
+ // Walk an iteration region: live has N rendered instances of the source's
568
+ // template. For each live top-level ELEMENT (rendered instance root), walk
569
+ // it against the corresponding template element (modulo template length).
570
+ // Static text/attr changes propagate to all instances; bound text/attrs
571
+ // are left to vibe (it owns those values via state).
572
+ //
573
+ // Caveat: if the iteration's array state changes after this update, vibe
574
+ // re-renders from its stored template (which we don't update), reverting
575
+ // our changes. The user can save again to re-propagate; perfect propagation
576
+ // would require updating vibe's iteration template metadata directly.
577
+ const reconcileIterationRegion = (liveParent, liveNodes, liStart, liEnd, srcNodes, siStart, sEnd, log) => {
578
+ // Per-instance attr/children walk so static text/attr tweaks propagate
579
+ // without a full re-render. Component callsites inside the template are
580
+ // NOT reconciled here — iterate.js caches the template at mount time, and
581
+ // the callsite's live wrapper has iteration-resolved props that never
582
+ // match the authored source. Re-authored component props inside an
583
+ // iteration need a page refresh to take effect (known HMR limitation).
584
+ const tplEls = [];
585
+ for (let i = siStart + 1; i < sEnd; i++) {
586
+ if (srcNodes[i].nodeType === ELEMENT) tplEls.push(srcNodes[i]);
587
+ }
588
+ if (!tplEls.length) return;
589
+
590
+ let instanceIdx = 0;
591
+ for (let i = liStart + 1; i < liEnd; i++) {
592
+ const liveEl = liveNodes[i];
593
+ if (liveEl.nodeType !== ELEMENT) continue;
594
+ const tplEl = tplEls[instanceIdx % tplEls.length];
595
+ instanceIdx++;
596
+ if (liveEl.tagName !== tplEl.tagName) continue; // shape changed; skip
597
+ reconcileAttributes(liveEl, tplEl, log);
598
+ reconcileChildren(liveEl, [...tplEl.childNodes], log, true);
599
+ }
600
+ };
601
+
602
+ // Public entry point. Parses `source` (HTML string or DOM element) into an
603
+ // inert <template>, then walks `target`'s children against it.
604
+ //
605
+ // Returns a Promise that resolves to a mutation summary object once vibe's
606
+ // MutationObserver has settled. The summary is `{ text, attr, insert,
607
+ // remove, replace, changes }` — counts plus a human-readable list of what
608
+ // was changed (useful for HMR debug logging).
609
+ export const reconcile = (target, source) => {
610
+ const live = typeof target === 'string' ? document.querySelector(target) : target;
611
+ if (!live) return Promise.resolve({ text: 0, attr: 0, insert: 0, remove: 0, replace: 0, changes: [] });
612
+
613
+ const log = { text: 0, attr: 0, insert: 0, remove: 0, replace: 0, changes: [] };
614
+ const tpl = document.createElement('template');
615
+ tpl.innerHTML = typeof source === 'string' ? source : source.outerHTML;
616
+ reconcileChildren(live, [...tpl.content.childNodes], log);
617
+
618
+ return new Promise((resolve) => {
619
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve(log)));
620
+ });
621
+ };
package/runtime/state.js CHANGED
@@ -1,11 +1,29 @@
1
1
  // Track which objects are already proxied to avoid double-wrapping
2
2
  const proxyCache = new WeakMap();
3
3
 
4
+ // Batching: collect mutations and flush once per microtask
5
+ let pendingFlush = false;
6
+ let flushCallback = null;
7
+ const changedProps = new Set();
8
+
9
+ const scheduleFlush = () => {
10
+ if (!pendingFlush) {
11
+ pendingFlush = true;
12
+ queueMicrotask(() => {
13
+ pendingFlush = false;
14
+ const props = [...changedProps];
15
+ changedProps.clear();
16
+ if (flushCallback) flushCallback(props);
17
+ });
18
+ }
19
+ };
20
+
4
21
  // Deep proxy: recursively wrap nested objects and arrays
5
22
  const createDeepProxy = (target, rerender, rootState = null, rootProp = null) => {
6
23
  // For root level, rootState is the target itself
7
24
  if (rootState === null) {
8
25
  rootState = target;
26
+ flushCallback = (props) => rerender(props);
9
27
  }
10
28
 
11
29
  // Check cache first
@@ -19,13 +37,11 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
19
37
 
20
38
  // Only trigger rerender if value actually changed
21
39
  if (oldValue !== value) {
22
- // Perform the mutation
23
40
  const ref = Reflect.set(obj, prop, value);
24
41
 
25
- // Trigger rerender with changed state
26
- // If we're nested, use the root prop; otherwise use the prop itself
27
- const changedProp = rootProp || prop;
28
- rerender({ [changedProp]: rootState[changedProp] }, null);
42
+ // Track which root-level property changed (for selective extraction)
43
+ changedProps.add(rootProp || prop);
44
+ scheduleFlush();
29
45
 
30
46
  return ref;
31
47
  }
@@ -37,8 +53,11 @@ const createDeepProxy = (target, rerender, rootState = null, rootProp = null) =>
37
53
  get(target, prop) {
38
54
  const value = Reflect.get(target, prop);
39
55
 
40
- // Don't proxy non-objects, functions, or null
41
- if (value === null || typeof value !== 'object' || typeof value === 'function') {
56
+ // Don't proxy non-objects, functions, null, or Promises.
57
+ // Promises aren't reactive state; wrapping them would also violate
58
+ // the Proxy invariant when exposed as non-writable/non-configurable
59
+ // properties (e.g. $.ready).
60
+ if (value === null || typeof value !== 'object' || typeof value === 'function' || value instanceof Promise) {
42
61
  return value;
43
62
  }
44
63